mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
feat(workspace): track issue_id on workspace operations
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user