mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Improve operator workflow QoL (#5291)
## Thinking Path > - Paperclip is a control plane operators use repeatedly to supervise agent companies. > - Common operator workflows depend on fast scanning of inboxes, issue sidebars, workspaces, cost totals, and runtime services. > - Several small UI and service gaps made those workflows slower or less clear. > - This pull request groups the operator-facing QoL changes that can stand alone from recovery and adapter work. > - The benefit is a denser, clearer board experience for issue triage and workspace operation. ## What Changed - Added inbox assignee/project grouping and issue list token/runtime totals. - Improved issue properties with removable blocker chips and workspace task links. - Improved execution workspace layout, runtime controls, issues tab default, and stopped-port reuse behavior. - Added mobile markdown/routine dialog fixes, page title company names, sidebar polish, and dashboard run task label cleanup. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/lib/inbox.test.ts ui/src/components/IssueProperties.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx server/src/__tests__/workspace-runtime.test.ts server/src/__tests__/costs-service.test.ts` ## Risks - Medium UI risk because this touches several operator surfaces. The branch is intentionally grouped around workflow/QoL files and keeps the file count below the Greptile limit. ## Model Used - OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
11ffd6f2c5
commit
424e81d087
47 changed files with 1739 additions and 250 deletions
|
|
@ -3,7 +3,17 @@ 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, issues, projects } from "@paperclipai/db";
|
||||
import {
|
||||
createDb,
|
||||
companies,
|
||||
agents,
|
||||
activityLog,
|
||||
costEvents,
|
||||
financeEvents,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { costService } from "../services/costs.ts";
|
||||
import { financeService } from "../services/finance.ts";
|
||||
import {
|
||||
|
|
@ -69,6 +79,8 @@ const mockCostService = vi.hoisted(() => ({
|
|||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
runCount: 0,
|
||||
runtimeMs: 0,
|
||||
}),
|
||||
windowSpend: vi.fn().mockResolvedValue([]),
|
||||
byProject: vi.fn().mockResolvedValue([]),
|
||||
|
|
@ -231,7 +243,9 @@ describe("cost routes", () => {
|
|||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1");
|
||||
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1");
|
||||
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1", {
|
||||
excludeRoot: false,
|
||||
});
|
||||
expect(res.body).toEqual({
|
||||
issueId: "issue-1",
|
||||
issueCount: 1,
|
||||
|
|
@ -240,6 +254,8 @@ describe("cost routes", () => {
|
|||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
runCount: 0,
|
||||
runtimeMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -393,6 +409,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
|||
afterEach(async () => {
|
||||
await db.delete(financeEvents);
|
||||
await db.delete(costEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
|
|
@ -612,9 +630,173 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
|||
inputTokens: 60,
|
||||
cachedInputTokens: 6,
|
||||
outputTokens: 12,
|
||||
runCount: 0,
|
||||
runtimeMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates run wall-clock duration across the recursive issue tree", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const grandchildIssueId = randomUUID();
|
||||
const siblingIssueId = 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: "Run Agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Root",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: "TST-1",
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Child",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
issueNumber: 2,
|
||||
identifier: "TST-2",
|
||||
},
|
||||
{
|
||||
id: grandchildIssueId,
|
||||
companyId,
|
||||
parentId: childIssueId,
|
||||
title: "Grandchild",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
issueNumber: 3,
|
||||
identifier: "TST-3",
|
||||
},
|
||||
{
|
||||
id: siblingIssueId,
|
||||
companyId,
|
||||
title: "Sibling",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
issueNumber: 4,
|
||||
identifier: "TST-4",
|
||||
},
|
||||
]);
|
||||
|
||||
const linkedViaContextRunId = randomUUID();
|
||||
const linkedViaActivityRunId = randomUUID();
|
||||
const grandchildRunId = randomUUID();
|
||||
const siblingRunId = randomUUID();
|
||||
const livePartialRunId = randomUUID();
|
||||
|
||||
await db.insert(heartbeatRuns).values([
|
||||
// 60s run linked to root via contextSnapshot.issueId
|
||||
{
|
||||
id: linkedViaContextRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "completed",
|
||||
startedAt: new Date("2026-04-10T00:00:00.000Z"),
|
||||
finishedAt: new Date("2026-04-10T00:01:00.000Z"),
|
||||
contextSnapshot: { issueId: rootIssueId },
|
||||
},
|
||||
// 120s run linked to child via activity_log
|
||||
{
|
||||
id: linkedViaActivityRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "completed",
|
||||
startedAt: new Date("2026-04-10T00:05:00.000Z"),
|
||||
finishedAt: new Date("2026-04-10T00:07:00.000Z"),
|
||||
},
|
||||
// 30s run linked to grandchild
|
||||
{
|
||||
id: grandchildRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "completed",
|
||||
startedAt: new Date("2026-04-10T00:10:00.000Z"),
|
||||
finishedAt: new Date("2026-04-10T00:10:30.000Z"),
|
||||
contextSnapshot: { issueId: grandchildIssueId },
|
||||
},
|
||||
// sibling run NOT under root – should be excluded
|
||||
{
|
||||
id: siblingRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "completed",
|
||||
startedAt: new Date("2026-04-10T00:20:00.000Z"),
|
||||
finishedAt: new Date("2026-04-10T00:21:00.000Z"),
|
||||
contextSnapshot: { issueId: siblingIssueId },
|
||||
},
|
||||
// Still-running run on child (no finishedAt) – should contribute (now - startedAt)
|
||||
{
|
||||
id: livePartialRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "on_demand",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - 5_000),
|
||||
contextSnapshot: { issueId: childIssueId },
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(activityLog).values({
|
||||
companyId,
|
||||
runId: linkedViaActivityRunId,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
agentId,
|
||||
action: "issue.checked_out",
|
||||
entityType: "issue",
|
||||
entityId: childIssueId,
|
||||
details: {},
|
||||
});
|
||||
|
||||
const summary = await costs.issueTreeSummary(companyId, rootIssueId);
|
||||
|
||||
expect(summary.issueCount).toBe(3);
|
||||
// 3 finished runs in tree (root, child via activity, grandchild) + 1 live run
|
||||
expect(summary.runCount).toBe(4);
|
||||
// 60s + 120s + 30s = 210s = 210_000ms from finished runs.
|
||||
// Live run adds ~5_000ms; allow some slack so the assertion isn't flaky.
|
||||
expect(summary.runtimeMs).toBeGreaterThanOrEqual(210_000 + 4_000);
|
||||
expect(summary.runtimeMs).toBeLessThan(210_000 + 60_000);
|
||||
|
||||
// excludeRoot drops the root issue's own runs (the 60s contextSnapshot run)
|
||||
// while keeping the child + grandchild runs and any live child run.
|
||||
const descendantsOnly = await costs.issueTreeSummary(companyId, rootIssueId, {
|
||||
excludeRoot: true,
|
||||
});
|
||||
expect(descendantsOnly.issueCount).toBe(2);
|
||||
expect(descendantsOnly.runCount).toBe(3);
|
||||
// 120s + 30s = 150s + ~5s live run
|
||||
expect(descendantsOnly.runtimeMs).toBeGreaterThanOrEqual(150_000 + 4_000);
|
||||
expect(descendantsOnly.runtimeMs).toBeLessThan(150_000 + 60_000);
|
||||
});
|
||||
|
||||
it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => {
|
||||
const companyId = randomUUID();
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ function registerModuleMocks() {
|
|||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
async function createApp(db: unknown = {}) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
|
|
@ -126,7 +126,7 @@ async function createApp() {
|
|||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use("/api", issueRoutes(db as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
|
@ -266,6 +266,88 @@ describe("issue activity event routes", () => {
|
|||
});
|
||||
}, 15_000);
|
||||
|
||||
it("logs successful_run_handoff_resolved when an in_progress issue transitions to done with a pending required handoff", async () => {
|
||||
const issue = { ...makeIssue(), status: "in_progress" };
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const handoffActivityRow = {
|
||||
entityId: issue.id,
|
||||
action: "issue.successful_run_handoff_required",
|
||||
agentId: issue.assigneeAgentId,
|
||||
runId: "run-1",
|
||||
details: {
|
||||
sourceRunId: "run-1",
|
||||
correctiveRunId: "run-2",
|
||||
},
|
||||
createdAt: new Date("2026-05-01T00:00:00.000Z"),
|
||||
};
|
||||
const dbMock = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: async () => [handoffActivityRow],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await request(await createApp(dbMock))
|
||||
.patch(`/api/issues/${issue.id}`)
|
||||
.send({ status: "done" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.successful_run_handoff_resolved",
|
||||
entityId: issue.id,
|
||||
details: expect.objectContaining({
|
||||
identifier: "PAP-580",
|
||||
sourceRunId: "run-1",
|
||||
correctiveRunId: "run-2",
|
||||
resolvedByStatus: "done",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not log successful_run_handoff_resolved when status stays in_progress", async () => {
|
||||
const issue = { ...makeIssue(), status: "in_progress" };
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const dbMock = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: async () => [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await request(await createApp(dbMock))
|
||||
.patch(`/api/issues/${issue.id}`)
|
||||
.send({ title: "Updated title" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ action: "issue.successful_run_handoff_resolved" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||
const existingPolicy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
|
|
|
|||
|
|
@ -3134,6 +3134,130 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
expect(persisted?.healthStatus).toBe("unknown");
|
||||
expect(persisted?.stoppedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("restarts a stopped auto-port service on the same port when it is available", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-port-reuse-"));
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const executionWorkspaceId = 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: "Codex Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Runtime port reuse test",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Execution workspace port reuse test",
|
||||
status: "active",
|
||||
cwd: workspaceRoot,
|
||||
providerType: "local_fs",
|
||||
providerRef: workspaceRoot,
|
||||
});
|
||||
|
||||
const actor = {
|
||||
id: agentId,
|
||||
name: "Codex Coder",
|
||||
companyId,
|
||||
};
|
||||
const workspace = {
|
||||
...buildWorkspace(workspaceRoot),
|
||||
projectId,
|
||||
workspaceId: null,
|
||||
};
|
||||
const config = {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
expose: {
|
||||
type: "url",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: {
|
||||
type: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const first = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor,
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]?.port).toBeGreaterThan(0);
|
||||
await expect(fetch(first[0]!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
await expect(fetch(first[0]!.url!)).rejects.toThrow();
|
||||
|
||||
const second = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor,
|
||||
issue: null,
|
||||
workspace,
|
||||
executionWorkspaceId,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0]?.id).toBe(first[0]?.id);
|
||||
expect(second[0]?.port).toBe(first[0]?.port);
|
||||
expect(second[0]?.url).toBe(first[0]?.url);
|
||||
await expect(fetch(second[0]!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||
|
|
|
|||
|
|
@ -145,7 +145,8 @@ export function costRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const summary = await costs.issueTreeSummary(issue.companyId, issue.id);
|
||||
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
|
||||
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
|
||||
import {
|
||||
|
|
@ -189,25 +189,27 @@ async function listSuccessfulRunHandoffStates(
|
|||
issueIds: string[],
|
||||
): Promise<Map<string, SuccessfulRunHandoffState>> {
|
||||
if (issueIds.length === 0) return new Map();
|
||||
const result = await db.execute(sql`
|
||||
SELECT DISTINCT ON (${activityLog.entityId})
|
||||
${activityLog.entityId} AS "entityId",
|
||||
${activityLog.action} AS "action",
|
||||
${activityLog.agentId} AS "agentId",
|
||||
${activityLog.runId} AS "runId",
|
||||
${activityLog.details} AS "details",
|
||||
${activityLog.createdAt} AS "createdAt"
|
||||
FROM ${activityLog}
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)})
|
||||
AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)})
|
||||
ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC
|
||||
`);
|
||||
const rows = Array.from(result as Iterable<SuccessfulRunHandoffActivityRow>);
|
||||
const rows = await db
|
||||
.select({
|
||||
entityId: activityLog.entityId,
|
||||
action: activityLog.action,
|
||||
agentId: activityLog.agentId,
|
||||
runId: activityLog.runId,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, issueIds),
|
||||
inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]),
|
||||
))
|
||||
.orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)) as SuccessfulRunHandoffActivityRow[];
|
||||
|
||||
const states = new Map<string, SuccessfulRunHandoffState>();
|
||||
for (const row of rows) {
|
||||
if (states.has(row.entityId)) continue;
|
||||
const state = successfulRunHandoffStateFromActivity(row);
|
||||
if (state) states.set(row.entityId, state);
|
||||
}
|
||||
|
|
@ -2546,6 +2548,33 @@ export function issueRoutes(
|
|||
},
|
||||
});
|
||||
|
||||
if (existing.status === "in_progress" && issue.status !== existing.status && issue.status !== "in_progress") {
|
||||
await listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id])
|
||||
.then(async (handoffStates) => {
|
||||
const handoff = handoffStates.get(issue.id);
|
||||
if (handoff?.state !== "required") return;
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.successful_run_handoff_resolved",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
sourceRunId: handoff.sourceRunId,
|
||||
correctiveRunId: handoff.correctiveRunId,
|
||||
resolvedByStatus: issue.status,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn({ err, issueId: issue.id }, "failed to log successful run handoff resolution");
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(req.body.blockedByIssueIds)) {
|
||||
const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id));
|
||||
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
|
||||
|
||||
|
|
@ -135,18 +135,53 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
|||
};
|
||||
},
|
||||
|
||||
issueTreeSummary: async (companyId: string, issueId: string) => {
|
||||
issueTreeSummary: async (
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
options: { excludeRoot?: boolean } = {},
|
||||
) => {
|
||||
// Callers must resolve and authorize a visible root issue before invoking this.
|
||||
// The route does that so zero counts are not mistaken for a missing root.
|
||||
const childIssues = alias(issues, "child");
|
||||
const issueTreeCondition = sql<boolean>`
|
||||
${issues.id} IN (
|
||||
WITH RECURSIVE issue_tree(id) AS (
|
||||
|
||||
// The seed of the recursive CTE: when excludeRoot is true, start from
|
||||
// the direct children so the root issue itself is not counted.
|
||||
const cteSeed = options.excludeRoot
|
||||
? sql`
|
||||
SELECT ${issues.id}
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.parentId} = ${issueId}
|
||||
AND ${issues.hiddenAt} IS NULL
|
||||
`
|
||||
: sql`
|
||||
SELECT ${issues.id}
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.id} = ${issueId}
|
||||
AND ${issues.hiddenAt} IS NULL
|
||||
`;
|
||||
|
||||
const cteSeedText = options.excludeRoot
|
||||
? sql`
|
||||
SELECT (${issues.id})::text AS id
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.parentId} = ${issueId}
|
||||
AND ${issues.hiddenAt} IS NULL
|
||||
`
|
||||
: sql`
|
||||
SELECT (${issues.id})::text AS id
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.id} = ${issueId}
|
||||
AND ${issues.hiddenAt} IS NULL
|
||||
`;
|
||||
|
||||
const issueTreeCondition = sql<boolean>`
|
||||
${issues.id} IN (
|
||||
WITH RECURSIVE issue_tree(id) AS (
|
||||
${cteSeed}
|
||||
UNION ALL
|
||||
SELECT ${childIssues.id}
|
||||
FROM ${issues} ${childIssues}
|
||||
|
|
@ -158,38 +193,80 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
|||
)
|
||||
`;
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
issueCount: sql<number>`count(distinct ${issues.id})::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(issues)
|
||||
.leftJoin(
|
||||
costEvents,
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
eq(costEvents.issueId, issues.id),
|
||||
),
|
||||
const runSummarySql = sql`
|
||||
WITH RECURSIVE issue_tree(id) AS (
|
||||
${cteSeedText}
|
||||
UNION ALL
|
||||
SELECT (${childIssues.id})::text
|
||||
FROM ${issues} ${childIssues}
|
||||
JOIN issue_tree ON (${childIssues.parentId})::text = issue_tree.id
|
||||
WHERE ${childIssues.companyId} = ${companyId}
|
||||
AND ${childIssues.hiddenAt} IS NULL
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
issueTreeCondition,
|
||||
SELECT
|
||||
count(distinct ${heartbeatRuns.id})::int AS "runCount",
|
||||
coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision AS "runtimeMs"
|
||||
FROM ${heartbeatRuns}
|
||||
WHERE ${heartbeatRuns.companyId} = ${companyId}
|
||||
AND ${heartbeatRuns.startedAt} IS NOT NULL
|
||||
AND (
|
||||
${heartbeatRuns.contextSnapshot} ->> 'issueId' IN (SELECT id FROM issue_tree)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${activityLog}
|
||||
JOIN issue_tree ON ${activityLog.entityId} = issue_tree.id
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.runId} = ${heartbeatRuns.id}
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
// Run cost-event aggregation and run-duration aggregation in parallel.
|
||||
// They're separate queries because cost_events fan out per-event and
|
||||
// joining heartbeat_runs through them would double-count run durations.
|
||||
const [costRowResult, runRowResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
issueCount: sql<number>`count(distinct ${issues.id})::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(issues)
|
||||
.leftJoin(
|
||||
costEvents,
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
eq(costEvents.issueId, issues.id),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
issueTreeCondition,
|
||||
),
|
||||
),
|
||||
);
|
||||
db.execute(runSummarySql),
|
||||
]);
|
||||
|
||||
const costRow = costRowResult[0];
|
||||
const runRow = Array.isArray(runRowResult)
|
||||
? (runRowResult[0] as { runCount?: number | string | null; runtimeMs?: number | string | null } | undefined)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
issueId,
|
||||
issueCount: Number(row?.issueCount ?? 0),
|
||||
issueCount: Number(costRow?.issueCount ?? 0),
|
||||
includeDescendants: true,
|
||||
costCents: Number(row?.costCents ?? 0),
|
||||
inputTokens: Number(row?.inputTokens ?? 0),
|
||||
cachedInputTokens: Number(row?.cachedInputTokens ?? 0),
|
||||
outputTokens: Number(row?.outputTokens ?? 0),
|
||||
costCents: Number(costRow?.costCents ?? 0),
|
||||
inputTokens: Number(costRow?.inputTokens ?? 0),
|
||||
cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0),
|
||||
outputTokens: Number(costRow?.outputTokens ?? 0),
|
||||
runCount: Number(runRow?.runCount ?? 0),
|
||||
runtimeMs: Number(runRow?.runtimeMs ?? 0),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,11 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
|
|||
processGroupId: number | null;
|
||||
}
|
||||
|
||||
type StoppedRuntimeServiceReuseCandidate = {
|
||||
id: string;
|
||||
port: number | null;
|
||||
};
|
||||
|
||||
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
||||
const runtimeServicesByReuseKey = new Map<string, string>();
|
||||
const runtimeServiceLeasesByRun = new Map<string, string[]>();
|
||||
|
|
@ -1815,6 +1820,33 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe
|
|||
});
|
||||
}
|
||||
|
||||
async function findStoppedRuntimeServiceReuseCandidate(input: {
|
||||
db?: Db;
|
||||
companyId: string;
|
||||
reuseKey: string | null;
|
||||
}): Promise<StoppedRuntimeServiceReuseCandidate | null> {
|
||||
if (!input.db || !input.reuseKey) return null;
|
||||
const row = await input.db
|
||||
.select({
|
||||
id: workspaceRuntimeServices.id,
|
||||
port: workspaceRuntimeServices.port,
|
||||
})
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceRuntimeServices.companyId, input.companyId),
|
||||
eq(workspaceRuntimeServices.reuseKey, input.reuseKey),
|
||||
eq(workspaceRuntimeServices.provider, "local_process"),
|
||||
eq(workspaceRuntimeServices.status, "stopped"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
function clearIdleTimer(record: RuntimeServiceRecord) {
|
||||
if (!record.idleTimer) return;
|
||||
clearTimeout(record.idleTimer);
|
||||
|
|
@ -1927,9 +1959,20 @@ async function startLocalRuntimeService(input: {
|
|||
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
|
||||
const explicitPort = identity.explicitPort;
|
||||
const identityPort = identity.identityPort;
|
||||
const stoppedReuseCandidate = await findStoppedRuntimeServiceReuseCandidate({
|
||||
db: input.db,
|
||||
companyId: input.agent.companyId,
|
||||
reuseKey: input.reuseKey,
|
||||
});
|
||||
const reusableStoppedPort =
|
||||
asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port
|
||||
? (await readLocalServicePortOwner(stoppedReuseCandidate.port))
|
||||
? null
|
||||
: stoppedReuseCandidate.port
|
||||
: null;
|
||||
const port =
|
||||
asString(portConfig.type, "") === "auto"
|
||||
? await allocatePort()
|
||||
? (reusableStoppedPort ?? await allocatePort())
|
||||
: explicitPort > 0
|
||||
? explicitPort
|
||||
: null;
|
||||
|
|
@ -2073,7 +2116,7 @@ async function startLocalRuntimeService(input: {
|
|||
}
|
||||
|
||||
const record: RuntimeServiceRecord = {
|
||||
id: randomUUID(),
|
||||
id: stoppedReuseCandidate?.id ?? randomUUID(),
|
||||
companyId: input.agent.companyId,
|
||||
projectId: input.workspace.projectId,
|
||||
projectWorkspaceId: input.workspace.workspaceId,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue