diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts new file mode 100644 index 00000000000..b2f54f92862 --- /dev/null +++ b/server/src/__tests__/company-skills-service.test.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, companySkills, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company skill service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companySkillService.list", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-"); + db = createDb(tempDb.connectionString); + svc = companySkillService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(companySkills); + await db.delete(companies); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("lists skills without exposing markdown content", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-heavy-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Heavy Skill\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + description: "Large skill used for list projection regression coverage.", + markdown: `# Heavy Skill\n\n${"x".repeat(250_000)}`, + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }); + + const listed = await svc.list(companyId); + const skill = listed.find((entry) => entry.id === skillId); + + expect(skill).toBeDefined(); + expect(skill).not.toHaveProperty("markdown"); + expect(skill).toMatchObject({ + id: skillId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + sourceType: "local_path", + sourceLocator: skillDir, + attachedAgentCount: 0, + sourceBadge: "local", + editable: true, + }); + }); +}); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 5ee13e4c06e..83e4e87b703 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -1,6 +1,15 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { randomUUID } from "node:crypto"; +import { createDb, companies, agents, costEvents, financeEvents, projects } from "@paperclipai/db"; +import { costService } from "../services/costs.ts"; +import { financeService } from "../services/finance.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; function makeDb(overrides: Record = {}) { const selectChain = { @@ -230,3 +239,154 @@ describe("cost routes", () => { expect(mockAgentService.update).not.toHaveBeenCalled(); }); }); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { + let db!: ReturnType; + let costs!: ReturnType; + let finance!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-costs-service-"); + db = createDb(tempDb.connectionString); + costs = costService(db); + finance = financeService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(financeEvents); + await db.delete(costEvents); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + + 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: "Cost Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Overflow Project", + status: "active", + }); + + await db.insert(costEvents).values([ + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 0, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 10, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const [byAgentRow] = await costs.byAgent(companyId, range); + const [byProjectRow] = await costs.byProject(companyId, range); + const [byAgentModelRow] = await costs.byAgentModel(companyId, range); + + expect(byAgentRow?.costCents).toBe(4_000_000_000); + expect(byAgentRow?.inputTokens).toBe(4_000_000_000); + expect(byProjectRow?.costCents).toBe(4_000_000_000); + expect(byAgentModelRow?.costCents).toBe(4_000_000_000); + }); + + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(financeEvents).values([ + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: false, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: true, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const summary = await finance.summary(companyId, range); + const [byKindRow] = await finance.byKind(companyId, range); + + expect(summary.debitCents).toBe(4_000_000_000); + expect(summary.estimatedDebitCents).toBe(2_000_000_000); + expect(byKindRow?.debitCents).toBe(4_000_000_000); + expect(byKindRow?.netCents).toBe(4_000_000_000); + }); +}); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index a7e05245760..75aefd6a364 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -698,6 +698,39 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { "2026-03-26T10:00:00.000Z", ); }); + + it("trims list payload fields that can grow large on issue index routes", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const longDescription = "x".repeat(5_000); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Large issue", + description: longDescription, + status: "todo", + priority: "medium", + executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) }, + executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) }, + executionWorkspaceSettings: { notes: "w".repeat(2_000) }, + }); + + const [result] = await svc.list(companyId); + + expect(result).toBeTruthy(); + expect(result?.description).toHaveLength(1200); + expect(result?.executionPolicy).toBeNull(); + expect(result?.executionState).toBeNull(); + expect(result?.executionWorkspaceSettings).toBeNull(); + }); }); describeEmbeddedPostgres("issueService.create workspace inheritance", () => { diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 6cacb441f50..131578de9cb 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -216,7 +216,7 @@ export function agentService(db: Db) { const rows = await db .select({ agentId: costEvents.agentId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( diff --git a/server/src/services/budgets.ts b/server/src/services/budgets.ts index 3e7d8b4e6cb..48575438386 100644 --- a/server/src/services/budgets.ts +++ b/server/src/services/budgets.ts @@ -156,7 +156,7 @@ async function computeObservedAmount( const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)); diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index dcdeb27dd3c..89678572ca8 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -76,10 +76,10 @@ export function companyService(db: Db) { if (companyIds.length === 0) return new Map(); const { start, end } = currentUtcMonthWindow(); const rows = await database - .select({ - companyId: costEvents.companyId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - }) + .select({ + companyId: costEvents.companyId, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, + }) .from(costEvents) .where( and( diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 60fc06b40f5..0985c2b6226 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -36,6 +36,50 @@ import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; +type CompanySkillListDbRow = Pick< + CompanySkillRow, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type CompanySkillListRow = Pick< + CompanySkill, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type SkillReferenceTarget = Pick; +type SkillSourceInfoTarget = Pick< + CompanySkill, + | "companyId" + | "sourceType" + | "sourceLocator" + | "metadata" +>; type ImportedSkill = { key: string; @@ -1150,6 +1194,28 @@ function toCompanySkill(row: CompanySkillRow): CompanySkill { }; } +function toCompanySkillListRow(row: CompanySkillListDbRow): CompanySkillListRow { + return { + ...row, + description: row.description ?? null, + sourceType: row.sourceType as CompanySkillSourceType, + sourceLocator: row.sourceLocator ?? null, + sourceRef: row.sourceRef ?? null, + trustLevel: row.trustLevel as CompanySkillTrustLevel, + compatibility: row.compatibility as CompanySkillCompatibility, + fileInventory: Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!isPlainRecord(entry)) return []; + return [{ + path: String(entry.path ?? ""), + kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]), + }]; + }) + : [], + metadata: isPlainRecord(row.metadata) ? row.metadata : null, + }; +} + function serializeFileInventory( fileInventory: CompanySkillFileInventoryEntry[], ): Array> { @@ -1159,14 +1225,14 @@ function serializeFileInventory( })); } -function getSkillMeta(skill: CompanySkill): SkillSourceMeta { +function getSkillMeta(skill: Pick): SkillSourceMeta { return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; } function resolveSkillReference( - skills: CompanySkill[], + skills: SkillReferenceTarget[], reference: string, -): { skill: CompanySkill | null; ambiguous: boolean } { +): { skill: SkillReferenceTarget | null; ambiguous: boolean } { const trimmed = reference.trim(); if (!trimmed) { return { skill: null, ambiguous: false }; @@ -1242,7 +1308,7 @@ function resolveRequestedSkillKeysOrThrow( } function resolveDesiredSkillKeys( - skills: CompanySkill[], + skills: SkillReferenceTarget[], config: Record, ) { const preference = readPaperclipSkillSyncPreference(config); @@ -1253,7 +1319,7 @@ function resolveDesiredSkillKeys( )); } -function normalizeSkillDirectory(skill: CompanySkill) { +function normalizeSkillDirectory(skill: SkillSourceInfoTarget) { if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null; const resolved = path.resolve(skill.sourceLocator); if (path.basename(resolved).toLowerCase() === "skill.md") { @@ -1329,7 +1395,7 @@ function isMarkdownPath(filePath: string) { return fileName === "skill.md" || fileName.endsWith(".md"); } -function deriveSkillSourceInfo(skill: CompanySkill): { +function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): { editable: boolean; editableReason: string | null; sourceLabel: string | null; @@ -1428,7 +1494,7 @@ function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgen }; } -function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number): CompanySkillListItem { +function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: number): CompanySkillListItem { const source = deriveSkillSourceInfo(skill); return { id: skill.id, @@ -1526,7 +1592,29 @@ export function companySkillService(db: Db) { } async function list(companyId: string): Promise { - const rows = await listFull(companyId); + await ensureSkillInventoryCurrent(companyId); + const rows = await db + .select({ + id: companySkills.id, + companyId: companySkills.companyId, + key: companySkills.key, + slug: companySkills.slug, + name: companySkills.name, + description: companySkills.description, + sourceType: companySkills.sourceType, + sourceLocator: companySkills.sourceLocator, + sourceRef: companySkills.sourceRef, + trustLevel: companySkills.trustLevel, + compatibility: companySkills.compatibility, + fileInventory: companySkills.fileInventory, + metadata: companySkills.metadata, + createdAt: companySkills.createdAt, + updatedAt: companySkills.updatedAt, + }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.key)) + .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); const agentRows = await agents.list(companyId); return rows.map((skill) => { const attachedAgentCount = agentRows.filter((agent) => { diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 76a90f2dad6..88825b498bd 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -12,6 +12,10 @@ export interface CostDateRange { const METERED_BILLING_TYPE = "metered_api"; const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const; +function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + function currentUtcMonthWindow(now = new Date()) { const year = now.getUTCFullYear(); const month = now.getUTCMonth(); @@ -36,7 +40,7 @@ async function getMonthlySpendTotal( } const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -111,7 +115,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { const [{ total }] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -140,26 +144,26 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { agentId: costEvents.agentId, agentName: agents.name, agentStatus: agents.status, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) .where(and(...conditions)) .groupBy(costEvents.agentId, agents.name, agents.status) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byProvider: async (companyId: string, range?: CostDateRange) => { @@ -173,25 +177,25 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byBiller: async (companyId: string, range?: CostDateRange) => { @@ -202,27 +206,27 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { return db .select({ biller: costEvents.biller, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, providerCount: sql`count(distinct ${costEvents.provider})::int`, modelCount: sql`count(distinct ${costEvents.model})::int`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.biller) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, /** @@ -244,10 +248,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .select({ provider: costEvents.provider, biller: sql`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .where( @@ -257,7 +261,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { ), ) .groupBy(costEvents.provider) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); return rows.map((row) => ({ provider: row.provider, @@ -292,10 +296,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) @@ -342,16 +346,16 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); - const costCentsExpr = sql`coalesce(sum(${costEvents.costCents}), 0)::int`; + const costCentsExpr = sumAsNumber(costEvents.costCents); return db .select({ projectId: effectiveProjectId, projectName: projects.name, costCents: costCentsExpr, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId)) diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index e495208d9cd..c1169aa98ca 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -65,7 +65,7 @@ export function dashboardService(db: Db) { const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const [{ monthSpend }] = await db .select({ - monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( diff --git a/server/src/services/finance.ts b/server/src/services/finance.ts index 29dcec494ab..766bea7b1f3 100644 --- a/server/src/services/finance.ts +++ b/server/src/services/finance.ts @@ -35,9 +35,9 @@ function rangeConditions(companyId: string, range?: FinanceDateRange) { } export function financeService(db: Db) { - const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`; + const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`; return { createEvent: async (companyId: string, data: Omit) => { @@ -95,12 +95,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, kindCount: sql`count(distinct ${financeEvents.eventKind})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.biller) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller); }, byKind: async (companyId: string, range?: FinanceDateRange) => { @@ -113,12 +113,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, billerCount: sql`count(distinct ${financeEvents.biller})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.eventKind) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind); }, list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 3736c543977..5d045e47ae0 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -132,6 +132,7 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { } const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); +const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200; function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); @@ -527,6 +528,51 @@ async function activeRunMapForIssues( return map; } +const issueListSelect = { + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + goalId: issues.goalId, + parentId: issues.parentId, + title: issues.title, + description: sql` + CASE + WHEN ${issues.description} IS NULL THEN NULL + ELSE substring(${issues.description} FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_CHARS}) + END + `, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + executionLockedAt: issues.executionLockedAt, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + issueNumber: issues.issueNumber, + identifier: issues.identifier, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, + requestDepth: issues.requestDepth, + billingCode: issues.billingCode, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionPolicy: sql`null`, + executionState: sql`null`, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + executionWorkspaceSettings: sql`null`, + startedAt: issues.startedAt, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + hiddenAt: issues.hiddenAt, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, +}; + function withActiveRuns( issueRows: IssueWithLabels[], runMap: Map, @@ -1005,7 +1051,7 @@ export function issueService(db: Db) { `; const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db - .select() + .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy( diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts index a7c7d4c768f..27d4e9c42d0 100644 --- a/ui/src/lib/issue-reference.test.ts +++ b/ui/src/lib/issue-reference.test.ts @@ -5,6 +5,7 @@ describe("issue-reference", () => { it("extracts issue ids from company-scoped issue paths", () => { expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271"); expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179"); + expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); }); it("extracts issue ids from full issue URLs", () => { @@ -36,4 +37,9 @@ describe("issue-reference", () => { href: "/issues/PAP-1271", }); }); + + it("ignores literal route placeholder paths", () => { + expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull(); + expect(parseIssueReferenceFromHref("http://localhost:3100/api/issues/:id")).toBeNull(); + }); }); diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts index 18de5a32440..25b4a4a27fd 100644 --- a/ui/src/lib/issue-reference.ts +++ b/ui/src/lib/issue-reference.ts @@ -25,7 +25,9 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): const segments = pathname.split("/").filter(Boolean); const issueIndex = segments.findIndex((segment) => segment === "issues"); if (issueIndex === -1 || issueIndex === segments.length - 1) return null; - return decodeURIComponent(segments[issueIndex + 1] ?? ""); + const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? ""); + if (!issuePathId || issuePathId.startsWith(":")) return null; + return issuePathId; } export function parseIssueReferenceFromHref(href: string | null | undefined) {