feat(workspace): track issue_id on workspace operations

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 13:33:23 +00:00
1 parent 2dbaf4a7fa
commit 859ec5bd4f
8 files changed
+87 -11

No files matched your search

@@ -0,0 +1,41 @@
ALTER TABLE "workspace_operations" ADD COLUMN IF NOT EXISTS "issue_id" uuid;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_operations_issue_id_issues_id_fk'
) THEN
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
WITH "run_issue_candidates" AS (
SELECT
"heartbeat_runs"."id" AS "run_id",
"heartbeat_runs"."company_id" AS "company_id",
CASE
WHEN "heartbeat_runs"."context_snapshot" ? 'issueId'
AND "heartbeat_runs"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
THEN ("heartbeat_runs"."context_snapshot"->>'issueId')::uuid
ELSE NULL
END AS "issue_id"
FROM "heartbeat_runs"
),
"run_issue_attribution" AS (
SELECT
"run_issue_candidates"."run_id",
"issues"."id" AS "issue_id"
FROM "run_issue_candidates"
INNER JOIN "issues"
ON "issues"."id" = "run_issue_candidates"."issue_id"
AND "issues"."company_id" = "run_issue_candidates"."company_id"
WHERE "run_issue_candidates"."issue_id" IS NOT NULL
)
UPDATE "workspace_operations"
SET
"issue_id" = "run_issue_attribution"."issue_id",
"updated_at" = now()
FROM "run_issue_attribution"
WHERE "workspace_operations"."heartbeat_run_id" = "run_issue_attribution"."run_id"
AND "workspace_operations"."issue_id" IS NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_operations_company_workspace_issue_started_idx" ON "workspace_operations" USING btree ("company_id","execution_workspace_id","issue_id","started_at");
@@ -12,6 +12,7 @@ import {
import { companies } from "./companies.js";
import { executionWorkspaces } from "./execution_workspaces.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
export const workspaceOperations = pgTable(
"workspace_operations",
@@ -24,6 +25,9 @@ export const workspaceOperations = pgTable(
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, {
onDelete: "set null",
}),
issueId: uuid("issue_id").references(() => issues.id, {
onDelete: "set null",
}),
phase: text("phase").notNull(),
command: text("command"),
cwd: text("cwd"),
@@ -53,5 +57,11 @@ export const workspaceOperations = pgTable(
table.executionWorkspaceId,
table.startedAt,
),
companyWorkspaceIssueStartedIdx: index("workspace_operations_company_workspace_issue_started_idx").on(
table.companyId,
table.executionWorkspaceId,
table.issueId,
table.startedAt,
),
}),
);
@@ -12,6 +12,7 @@ export interface WorkspaceOperation {
companyId: string;
executionWorkspaceId: string | null;
heartbeatRunId: string | null;
issueId: string | null;
phase: WorkspaceOperationPhase;
command: string | null;
cwd: string | null;
@@ -200,6 +200,7 @@ function createWorkspaceOperationRecorderDouble() {
companyId: "company-1",
executionWorkspaceId,
heartbeatRunId: "run-1",
issueId: null,
phase: input.phase,
command: input.command ?? null,
cwd: input.cwd ?? null,
@@ -16,6 +16,7 @@ function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation {
companyId: row.companyId,
executionWorkspaceId: row.executionWorkspaceId ?? null,
heartbeatRunId: row.heartbeatRunId ?? null,
issueId: row.issueId ?? null,
phase: row.phase as WorkspaceOperationPhase,
command: row.command ?? null,
cwd: row.cwd ?? null,
@@ -89,6 +90,7 @@ export function workspaceOperationService(db: Db) {
companyId: string;
heartbeatRunId?: string | null;
executionWorkspaceId?: string | null;
issueId?: string | null;
}): WorkspaceOperationRecorder {
let executionWorkspaceId = input.executionWorkspaceId ?? null;
const createdIds: string[] = [];
@@ -136,6 +138,7 @@ export function workspaceOperationService(db: Db) {
companyId: input.companyId,
executionWorkspaceId,
heartbeatRunId: input.heartbeatRunId ?? null,
issueId: input.issueId ?? null,
phase: recordInput.phase,
command: recordInput.command ?? null,
cwd: recordInput.cwd ?? null,
+1 -1
View File
@@ -83,7 +83,7 @@ export function WorkspaceFileLink({
aria-label={ariaLabel}
title={tooltip}
className={cn(
"paperclip-workspace-file-link inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
"paperclip-workspace-file-link inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-middle no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
className,
)}
onClick={handleClick}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type { WorkspaceRuntimeService } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -14,6 +14,10 @@ import {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(callback);
}
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
return {
id: overrides.id ?? "service-1",
+25 -9
View File
@@ -226,12 +226,14 @@ function CommandActionButtons({
pendingRequest,
onAction,
square,
iconOnly,
}: {
item: WorkspaceRuntimeControlItem;
isPending: boolean;
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
square?: boolean;
iconOnly?: boolean;
}) {
const actions: WorkspaceRuntimeAction[] =
item.kind === "job"
@@ -241,7 +243,7 @@ function CommandActionButtons({
: ["start"];
return (
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
<div className={cn("flex gap-2", iconOnly ? "w-auto flex-row flex-wrap" : "w-full flex-col sm:w-auto sm:flex-row sm:flex-wrap")}>
{actions.map((action) => {
const request = buildRequest(item, action);
const Icon = action === "stop" ? Square : action === "restart" ? RotateCcw : Play;
@@ -261,17 +263,19 @@ function CommandActionButtons({
return (
<Button
key={`${item.key}:${action}`}
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
size="sm"
variant={iconOnly ? "outline" : action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
size={iconOnly ? "icon-xs" : "sm"}
className={cn(
"w-full justify-start sm:w-auto",
iconOnly ? "shrink-0" : "w-full justify-start sm:w-auto",
square ? "rounded-none" : null,
)}
disabled={disabled}
onClick={() => onAction(request)}
aria-label={label}
title={label}
>
{showSpinner ? <Loader2 className="h-4 w-4 animate-spin" /> : <Icon className="h-4 w-4" />}
{label}
{iconOnly ? <span className="sr-only">{label}</span> : label}
</Button>
);
})}
@@ -289,6 +293,7 @@ function CommandSection({
pendingRequest,
onAction,
square,
iconOnly,
}: {
title: string;
description: string;
@@ -299,6 +304,7 @@ function CommandSection({
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
square?: boolean;
iconOnly?: boolean;
}) {
return (
<div className="space-y-3">
@@ -330,6 +336,7 @@ function CommandSection({
pendingRequest={pendingRequest}
onAction={onAction}
square={square}
iconOnly={iconOnly}
/>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
@@ -466,24 +473,29 @@ export function WorkspaceRuntimeQuickControls({
pendingRequest = null,
onAction,
square,
align = "end",
iconOnly = false,
}: {
sections: WorkspaceRuntimeControlSections;
isPending?: boolean;
pendingRequest?: WorkspaceRuntimeControlRequest | null;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
square?: boolean;
align?: "start" | "end";
iconOnly?: boolean;
}) {
const controlItems = sections.services.length > 0 ? sections.services : sections.otherServices;
const serviceUrl = getRunningRuntimeServiceUrl(sections);
const alignEnd = align === "end";
if (controlItems.length === 0 && !serviceUrl) return null;
return (
<div className="flex min-w-0 flex-col items-stretch gap-2 sm:items-end">
<div className={cn("flex min-w-0 flex-col items-stretch gap-2", alignEnd ? "sm:items-end" : "sm:items-start")}>
{controlItems.length > 0 ? (
<div className="flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
<div className={cn("flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap", alignEnd ? "sm:justify-end" : "sm:justify-start")}>
{controlItems.map((item) => (
<div key={item.key} className="flex min-w-0 flex-col gap-1 sm:items-end">
<div key={item.key} className={cn("flex min-w-0 flex-col gap-1", alignEnd ? "sm:items-end" : "sm:items-start")}>
{controlItems.length > 1 ? (
<span className="truncate text-xs text-muted-foreground">{item.title}</span>
) : null}
@@ -493,6 +505,7 @@ export function WorkspaceRuntimeQuickControls({
pendingRequest={pendingRequest}
onAction={onAction}
square={square}
iconOnly={iconOnly}
/>
</div>
))}
@@ -503,7 +516,10 @@ export function WorkspaceRuntimeQuickControls({
href={serviceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline sm:self-end"
className={cn(
"inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline",
alignEnd ? "sm:self-end" : "sm:self-start",
)}
>
{serviceUrl}
<ExternalLink className="h-3.5 w-3.5 shrink-0" />