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:
Dotta 2026-05-06 06:30:44 -05:00 committed by GitHub
parent 11ffd6f2c5
commit 424e81d087
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1739 additions and 250 deletions

View file

@ -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();

View file

@ -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: [

View file

@ -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", () => {