From 859ec5bd4f381d8e1d3e982ea13e7b4414cb3b1d Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 23 Jun 2026 10:22:05 +0000 Subject: [PATCH] feat(workspace): track issue_id on workspace operations Co-Authored-By: Paperclip --- .../0106_workspace_operations_issue_id.sql | 41 +++++++++++++++++++ .../db/src/schema/workspace_operations.ts | 10 +++++ .../shared/src/types/workspace-operation.ts | 1 + .../src/__tests__/workspace-runtime.test.ts | 1 + server/src/services/workspace-operations.ts | 3 ++ ui/src/components/WorkspaceFileLink.tsx | 2 +- .../WorkspaceRuntimeControls.test.tsx | 6 ++- .../components/WorkspaceRuntimeControls.tsx | 34 +++++++++++---- 8 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 packages/db/src/migrations/0106_workspace_operations_issue_id.sql diff --git a/packages/db/src/migrations/0106_workspace_operations_issue_id.sql b/packages/db/src/migrations/0106_workspace_operations_issue_id.sql new file mode 100644 index 00000000000..c485a21ba6f --- /dev/null +++ b/packages/db/src/migrations/0106_workspace_operations_issue_id.sql @@ -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"); diff --git a/packages/db/src/schema/workspace_operations.ts b/packages/db/src/schema/workspace_operations.ts index fd9624681bb..8924d5e7b33 100644 --- a/packages/db/src/schema/workspace_operations.ts +++ b/packages/db/src/schema/workspace_operations.ts @@ -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, + ), }), ); diff --git a/packages/shared/src/types/workspace-operation.ts b/packages/shared/src/types/workspace-operation.ts index 12e329032e7..30043396c98 100644 --- a/packages/shared/src/types/workspace-operation.ts +++ b/packages/shared/src/types/workspace-operation.ts @@ -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; diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 9ba8780e938..b2972bc5421 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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, diff --git a/server/src/services/workspace-operations.ts b/server/src/services/workspace-operations.ts index 9509e9824bc..1c0c13a824f 100644 --- a/server/src/services/workspace-operations.ts +++ b/server/src/services/workspace-operations.ts @@ -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, diff --git a/ui/src/components/WorkspaceFileLink.tsx b/ui/src/components/WorkspaceFileLink.tsx index 0075a55973c..522b1580bb9 100644 --- a/ui/src/components/WorkspaceFileLink.tsx +++ b/ui/src/components/WorkspaceFileLink.tsx @@ -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} diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index 606556de5dd..6442c676a9c 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -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 { return { id: overrides.id ?? "service-1", diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx index cc03903e130..46ae26013a2 100644 --- a/ui/src/components/WorkspaceRuntimeControls.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.tsx @@ -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 ( -
+
{actions.map((action) => { const request = buildRequest(item, action); const Icon = action === "stop" ? Square : action === "restart" ? RotateCcw : Play; @@ -261,17 +263,19 @@ function CommandActionButtons({ return ( ); })} @@ -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 (
@@ -330,6 +336,7 @@ function CommandSection({ pendingRequest={pendingRequest} onAction={onAction} square={square} + iconOnly={iconOnly} />
@@ -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 ( -
+
{controlItems.length > 0 ? ( -
+
{controlItems.map((item) => ( -
+
{controlItems.length > 1 ? ( {item.title} ) : null} @@ -493,6 +505,7 @@ export function WorkspaceRuntimeQuickControls({ pendingRequest={pendingRequest} onAction={onAction} square={square} + iconOnly={iconOnly} />
))} @@ -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}