Implement execution workspaces and work products
This commit is contained in:
@@ -83,6 +83,7 @@ type EmbeddedPostgresCtor = new (opts: {
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
@@ -17,6 +17,7 @@ type EmbeddedPostgresCtor = new (opts: {
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
91
packages/db/src/migrations/0028_unusual_the_hunter.sql
Normal file
91
packages/db/src/migrations/0028_unusual_the_hunter.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
CREATE TABLE "execution_workspaces" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"project_id" uuid NOT NULL,
|
||||
"project_workspace_id" uuid,
|
||||
"source_issue_id" uuid,
|
||||
"mode" text NOT NULL,
|
||||
"strategy_type" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"cwd" text,
|
||||
"repo_url" text,
|
||||
"base_ref" text,
|
||||
"branch_name" text,
|
||||
"provider_type" text DEFAULT 'local_fs' NOT NULL,
|
||||
"provider_ref" text,
|
||||
"derived_from_execution_workspace_id" uuid,
|
||||
"last_used_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"opened_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"closed_at" timestamp with time zone,
|
||||
"cleanup_eligible_at" timestamp with time zone,
|
||||
"cleanup_reason" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "issue_work_products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"project_id" uuid,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"execution_workspace_id" uuid,
|
||||
"runtime_service_id" uuid,
|
||||
"type" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"external_id" text,
|
||||
"title" text NOT NULL,
|
||||
"url" text,
|
||||
"status" text NOT NULL,
|
||||
"review_state" text DEFAULT 'none' NOT NULL,
|
||||
"is_primary" boolean DEFAULT false NOT NULL,
|
||||
"health_status" text DEFAULT 'unknown' NOT NULL,
|
||||
"summary" text,
|
||||
"metadata" jsonb,
|
||||
"created_by_run_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN "project_workspace_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN "execution_workspace_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN "execution_workspace_preference" text;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "source_type" text DEFAULT 'local_path' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "default_ref" text;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "visibility" text DEFAULT 'default' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "setup_command" text;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "cleanup_command" text;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "remote_provider" text;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "remote_workspace_ref" text;--> statement-breakpoint
|
||||
ALTER TABLE "project_workspaces" ADD COLUMN "shared_workspace_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_runtime_services" ADD COLUMN "execution_workspace_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("derived_from_execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk" FOREIGN KEY ("runtime_service_id") REFERENCES "public"."workspace_runtime_services"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_work_products" ADD CONSTRAINT "issue_work_products_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspaces_company_project_status_idx" ON "execution_workspaces" USING btree ("company_id","project_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspaces_company_project_workspace_status_idx" ON "execution_workspaces" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspaces_company_source_issue_idx" ON "execution_workspaces" USING btree ("company_id","source_issue_id");--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspaces_company_last_used_idx" ON "execution_workspaces" USING btree ("company_id","last_used_at");--> statement-breakpoint
|
||||
CREATE INDEX "execution_workspaces_company_branch_idx" ON "execution_workspaces" USING btree ("company_id","branch_name");--> statement-breakpoint
|
||||
CREATE INDEX "issue_work_products_company_issue_type_idx" ON "issue_work_products" USING btree ("company_id","issue_id","type");--> statement-breakpoint
|
||||
CREATE INDEX "issue_work_products_company_execution_workspace_type_idx" ON "issue_work_products" USING btree ("company_id","execution_workspace_id","type");--> statement-breakpoint
|
||||
CREATE INDEX "issue_work_products_company_provider_external_id_idx" ON "issue_work_products" USING btree ("company_id","provider","external_id");--> statement-breakpoint
|
||||
CREATE INDEX "issue_work_products_company_updated_idx" ON "issue_work_products" USING btree ("company_id","updated_at");--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD CONSTRAINT "issues_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD CONSTRAINT "issues_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "issues_company_project_workspace_idx" ON "issues" USING btree ("company_id","project_workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "issues_company_execution_workspace_idx" ON "issues" USING btree ("company_id","execution_workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "project_workspaces_project_source_type_idx" ON "project_workspaces" USING btree ("project_id","source_type");--> statement-breakpoint
|
||||
CREATE INDEX "project_workspaces_company_shared_key_idx" ON "project_workspaces" USING btree ("company_id","shared_workspace_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "project_workspaces_project_remote_ref_idx" ON "project_workspaces" USING btree ("project_id","remote_provider","remote_workspace_ref");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_runtime_services_company_execution_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","execution_workspace_id","status");
|
||||
7125
packages/db/src/migrations/meta/0028_snapshot.json
Normal file
7125
packages/db/src/migrations/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
||||
"when": 1773150731736,
|
||||
"tag": "0027_tranquil_tenebrous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1773439626334,
|
||||
"tag": "0028_unusual_the_hunter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
68
packages/db/src/schema/execution_workspaces.ts
Normal file
68
packages/db/src/schema/execution_workspaces.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
type AnyPgColumn,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { projectWorkspaces } from "./project_workspaces.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const executionWorkspaces = pgTable(
|
||||
"execution_workspaces",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||
sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
|
||||
mode: text("mode").notNull(),
|
||||
strategyType: text("strategy_type").notNull(),
|
||||
name: text("name").notNull(),
|
||||
status: text("status").notNull().default("active"),
|
||||
cwd: text("cwd"),
|
||||
repoUrl: text("repo_url"),
|
||||
baseRef: text("base_ref"),
|
||||
branchName: text("branch_name"),
|
||||
providerType: text("provider_type").notNull().default("local_fs"),
|
||||
providerRef: text("provider_ref"),
|
||||
derivedFromExecutionWorkspaceId: uuid("derived_from_execution_workspace_id")
|
||||
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
openedAt: timestamp("opened_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
closedAt: timestamp("closed_at", { withTimezone: true }),
|
||||
cleanupEligibleAt: timestamp("cleanup_eligible_at", { withTimezone: true }),
|
||||
cleanupReason: text("cleanup_reason"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyProjectStatusIdx: index("execution_workspaces_company_project_status_idx").on(
|
||||
table.companyId,
|
||||
table.projectId,
|
||||
table.status,
|
||||
),
|
||||
companyProjectWorkspaceStatusIdx: index("execution_workspaces_company_project_workspace_status_idx").on(
|
||||
table.companyId,
|
||||
table.projectWorkspaceId,
|
||||
table.status,
|
||||
),
|
||||
companySourceIssueIdx: index("execution_workspaces_company_source_issue_idx").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
),
|
||||
companyLastUsedIdx: index("execution_workspaces_company_last_used_idx").on(
|
||||
table.companyId,
|
||||
table.lastUsedAt,
|
||||
),
|
||||
companyBranchIdx: index("execution_workspaces_company_branch_idx").on(
|
||||
table.companyId,
|
||||
table.branchName,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -13,10 +13,12 @@ export { agentTaskSessions } from "./agent_task_sessions.js";
|
||||
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
||||
export { projects } from "./projects.js";
|
||||
export { projectWorkspaces } from "./project_workspaces.js";
|
||||
export { executionWorkspaces } from "./execution_workspaces.js";
|
||||
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issueWorkProducts } from "./issue_work_products.js";
|
||||
export { labels } from "./labels.js";
|
||||
export { issueLabels } from "./issue_labels.js";
|
||||
export { issueApprovals } from "./issue_approvals.js";
|
||||
|
||||
64
packages/db/src/schema/issue_work_products.ts
Normal file
64
packages/db/src/schema/issue_work_products.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { executionWorkspaces } from "./execution_workspaces.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
|
||||
export const issueWorkProducts = pgTable(
|
||||
"issue_work_products",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
executionWorkspaceId: uuid("execution_workspace_id")
|
||||
.references(() => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
runtimeServiceId: uuid("runtime_service_id")
|
||||
.references(() => workspaceRuntimeServices.id, { onDelete: "set null" }),
|
||||
type: text("type").notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
externalId: text("external_id"),
|
||||
title: text("title").notNull(),
|
||||
url: text("url"),
|
||||
status: text("status").notNull(),
|
||||
reviewState: text("review_state").notNull().default("none"),
|
||||
isPrimary: boolean("is_primary").notNull().default(false),
|
||||
healthStatus: text("health_status").notNull().default("unknown"),
|
||||
summary: text("summary"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueTypeIdx: index("issue_work_products_company_issue_type_idx").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.type,
|
||||
),
|
||||
companyExecutionWorkspaceTypeIdx: index("issue_work_products_company_execution_workspace_type_idx").on(
|
||||
table.companyId,
|
||||
table.executionWorkspaceId,
|
||||
table.type,
|
||||
),
|
||||
companyProviderExternalIdIdx: index("issue_work_products_company_provider_external_id_idx").on(
|
||||
table.companyId,
|
||||
table.provider,
|
||||
table.externalId,
|
||||
),
|
||||
companyUpdatedIdx: index("issue_work_products_company_updated_idx").on(
|
||||
table.companyId,
|
||||
table.updatedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -14,6 +14,8 @@ import { projects } from "./projects.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { projectWorkspaces } from "./project_workspaces.js";
|
||||
import { executionWorkspaces } from "./execution_workspaces.js";
|
||||
|
||||
export const issues = pgTable(
|
||||
"issues",
|
||||
@@ -21,6 +23,7 @@ export const issues = pgTable(
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
projectId: uuid("project_id").references(() => projects.id),
|
||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||
goalId: uuid("goal_id").references(() => goals.id),
|
||||
parentId: uuid("parent_id").references((): AnyPgColumn => issues.id),
|
||||
title: text("title").notNull(),
|
||||
@@ -40,6 +43,9 @@ export const issues = pgTable(
|
||||
requestDepth: integer("request_depth").notNull().default(0),
|
||||
billingCode: text("billing_code"),
|
||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||
executionWorkspaceId: uuid("execution_workspace_id")
|
||||
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
executionWorkspacePreference: text("execution_workspace_preference"),
|
||||
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
@@ -62,6 +68,8 @@ export const issues = pgTable(
|
||||
),
|
||||
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
|
||||
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
|
||||
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
@@ -17,9 +18,17 @@ export const projectWorkspaces = pgTable(
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
sourceType: text("source_type").notNull().default("local_path"),
|
||||
cwd: text("cwd"),
|
||||
repoUrl: text("repo_url"),
|
||||
repoRef: text("repo_ref"),
|
||||
defaultRef: text("default_ref"),
|
||||
visibility: text("visibility").notNull().default("default"),
|
||||
setupCommand: text("setup_command"),
|
||||
cleanupCommand: text("cleanup_command"),
|
||||
remoteProvider: text("remote_provider"),
|
||||
remoteWorkspaceRef: text("remote_workspace_ref"),
|
||||
sharedWorkspaceKey: text("shared_workspace_key"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
isPrimary: boolean("is_primary").notNull().default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -28,5 +37,9 @@ export const projectWorkspaces = pgTable(
|
||||
(table) => ({
|
||||
companyProjectIdx: index("project_workspaces_company_project_idx").on(table.companyId, table.projectId),
|
||||
projectPrimaryIdx: index("project_workspaces_project_primary_idx").on(table.projectId, table.isPrimary),
|
||||
projectSourceTypeIdx: index("project_workspaces_project_source_type_idx").on(table.projectId, table.sourceType),
|
||||
companySharedKeyIdx: index("project_workspaces_company_shared_key_idx").on(table.companyId, table.sharedWorkspaceKey),
|
||||
projectRemoteRefIdx: uniqueIndex("project_workspaces_project_remote_ref_idx")
|
||||
.on(table.projectId, table.remoteProvider, table.remoteWorkspaceRef),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { companies } from "./companies.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { projectWorkspaces } from "./project_workspaces.js";
|
||||
import { executionWorkspaces } from "./execution_workspaces.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
@@ -21,6 +22,7 @@ export const workspaceRuntimeServices = pgTable(
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
scopeType: text("scope_type").notNull(),
|
||||
scopeId: text("scope_id"),
|
||||
@@ -50,6 +52,11 @@ export const workspaceRuntimeServices = pgTable(
|
||||
table.projectWorkspaceId,
|
||||
table.status,
|
||||
),
|
||||
companyExecutionWorkspaceStatusIdx: index("workspace_runtime_services_company_execution_workspace_status_idx").on(
|
||||
table.companyId,
|
||||
table.executionWorkspaceId,
|
||||
table.status,
|
||||
),
|
||||
companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on(
|
||||
table.companyId,
|
||||
table.projectId,
|
||||
|
||||
@@ -77,12 +77,21 @@ export type {
|
||||
Project,
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
ExecutionWorkspace,
|
||||
WorkspaceRuntimeService,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceProviderType,
|
||||
ExecutionWorkspaceStatus,
|
||||
ExecutionWorkspaceStrategy,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
ProjectExecutionWorkspaceDefaultMode,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
IssueWorkProduct,
|
||||
IssueWorkProductType,
|
||||
IssueWorkProductProvider,
|
||||
IssueWorkProductStatus,
|
||||
IssueWorkProductReviewState,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueComment,
|
||||
@@ -172,6 +181,13 @@ export {
|
||||
addIssueCommentSchema,
|
||||
linkIssueApprovalSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
createIssueWorkProductSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
issueWorkProductTypeSchema,
|
||||
issueWorkProductStatusSchema,
|
||||
issueWorkProductReviewStateSchema,
|
||||
updateExecutionWorkspaceSchema,
|
||||
executionWorkspaceStatusSchema,
|
||||
type CreateIssue,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
@@ -179,6 +195,9 @@ export {
|
||||
type AddIssueComment,
|
||||
type LinkIssueApproval,
|
||||
type CreateIssueAttachmentMetadata,
|
||||
type CreateIssueWorkProduct,
|
||||
type UpdateIssueWorkProduct,
|
||||
type UpdateExecutionWorkspace,
|
||||
createGoalSchema,
|
||||
updateGoalSchema,
|
||||
type CreateGoal,
|
||||
|
||||
@@ -12,13 +12,24 @@ export type {
|
||||
export type { AssetImage } from "./asset.js";
|
||||
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
ExecutionWorkspace,
|
||||
WorkspaceRuntimeService,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceProviderType,
|
||||
ExecutionWorkspaceStatus,
|
||||
ExecutionWorkspaceStrategy,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
ProjectExecutionWorkspaceDefaultMode,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
} from "./workspace-runtime.js";
|
||||
export type {
|
||||
IssueWorkProduct,
|
||||
IssueWorkProductType,
|
||||
IssueWorkProductProvider,
|
||||
IssueWorkProductStatus,
|
||||
IssueWorkProductReviewState,
|
||||
} from "./work-product.js";
|
||||
export type {
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { IssuePriority, IssueStatus } from "../constants.js";
|
||||
import type { Goal } from "./goal.js";
|
||||
import type { Project, ProjectWorkspace } from "./project.js";
|
||||
import type { IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
||||
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
||||
import type { IssueWorkProduct } from "./work-product.js";
|
||||
|
||||
export interface IssueAncestorProject {
|
||||
id: string;
|
||||
@@ -54,6 +55,7 @@ export interface Issue {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
goalId: string | null;
|
||||
parentId: string | null;
|
||||
ancestors?: IssueAncestor[];
|
||||
@@ -74,6 +76,8 @@ export interface Issue {
|
||||
requestDepth: number;
|
||||
billingCode: string | null;
|
||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||
executionWorkspaceId: string | null;
|
||||
executionWorkspacePreference: string | null;
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
@@ -83,6 +87,8 @@ export interface Issue {
|
||||
labels?: IssueLabel[];
|
||||
project?: Project | null;
|
||||
goal?: Goal | null;
|
||||
currentExecutionWorkspace?: ExecutionWorkspace | null;
|
||||
workProducts?: IssueWorkProduct[];
|
||||
mentionedProjects?: Project[];
|
||||
myLastTouchAt?: Date | null;
|
||||
lastExternalCommentAt?: Date | null;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ProjectStatus } from "../constants.js";
|
||||
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||
|
||||
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
|
||||
export type ProjectWorkspaceVisibility = "default" | "advanced";
|
||||
|
||||
export interface ProjectGoalRef {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -11,9 +14,17 @@ export interface ProjectWorkspace {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sourceType: ProjectWorkspaceSourceType;
|
||||
cwd: string | null;
|
||||
repoUrl: string | null;
|
||||
repoRef: string | null;
|
||||
defaultRef: string | null;
|
||||
visibility: ProjectWorkspaceVisibility;
|
||||
setupCommand: string | null;
|
||||
cleanupCommand: string | null;
|
||||
remoteProvider: string | null;
|
||||
remoteWorkspaceRef: string | null;
|
||||
sharedWorkspaceKey: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
isPrimary: boolean;
|
||||
runtimeServices?: WorkspaceRuntimeService[];
|
||||
|
||||
55
packages/shared/src/types/work-product.ts
Normal file
55
packages/shared/src/types/work-product.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type IssueWorkProductType =
|
||||
| "preview_url"
|
||||
| "runtime_service"
|
||||
| "pull_request"
|
||||
| "branch"
|
||||
| "commit"
|
||||
| "artifact"
|
||||
| "document";
|
||||
|
||||
export type IssueWorkProductProvider =
|
||||
| "paperclip"
|
||||
| "github"
|
||||
| "vercel"
|
||||
| "s3"
|
||||
| "custom";
|
||||
|
||||
export type IssueWorkProductStatus =
|
||||
| "active"
|
||||
| "ready_for_review"
|
||||
| "approved"
|
||||
| "changes_requested"
|
||||
| "merged"
|
||||
| "closed"
|
||||
| "failed"
|
||||
| "archived"
|
||||
| "draft";
|
||||
|
||||
export type IssueWorkProductReviewState =
|
||||
| "none"
|
||||
| "needs_board_review"
|
||||
| "approved"
|
||||
| "changes_requested";
|
||||
|
||||
export interface IssueWorkProduct {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
issueId: string;
|
||||
executionWorkspaceId: string | null;
|
||||
runtimeServiceId: string | null;
|
||||
type: IssueWorkProductType;
|
||||
provider: IssueWorkProductProvider | string;
|
||||
externalId: string | null;
|
||||
title: string;
|
||||
url: string | null;
|
||||
status: IssueWorkProductStatus | string;
|
||||
reviewState: IssueWorkProductReviewState;
|
||||
isPrimary: boolean;
|
||||
healthStatus: "unknown" | "healthy" | "unhealthy";
|
||||
summary: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdByRunId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,6 +1,35 @@
|
||||
export type ExecutionWorkspaceStrategyType = "project_primary" | "git_worktree";
|
||||
export type ExecutionWorkspaceStrategyType =
|
||||
| "project_primary"
|
||||
| "git_worktree"
|
||||
| "adapter_managed"
|
||||
| "cloud_sandbox";
|
||||
|
||||
export type ExecutionWorkspaceMode = "inherit" | "project_primary" | "isolated" | "agent_default";
|
||||
export type ProjectExecutionWorkspaceDefaultMode =
|
||||
| "shared_workspace"
|
||||
| "isolated_workspace"
|
||||
| "operator_branch"
|
||||
| "adapter_default";
|
||||
|
||||
export type ExecutionWorkspaceMode =
|
||||
| "inherit"
|
||||
| "shared_workspace"
|
||||
| "isolated_workspace"
|
||||
| "operator_branch"
|
||||
| "reuse_existing"
|
||||
| "agent_default";
|
||||
|
||||
export type ExecutionWorkspaceProviderType =
|
||||
| "local_fs"
|
||||
| "git_worktree"
|
||||
| "adapter_managed"
|
||||
| "cloud_sandbox";
|
||||
|
||||
export type ExecutionWorkspaceStatus =
|
||||
| "active"
|
||||
| "idle"
|
||||
| "in_review"
|
||||
| "archived"
|
||||
| "cleanup_failed";
|
||||
|
||||
export interface ExecutionWorkspaceStrategy {
|
||||
type: ExecutionWorkspaceStrategyType;
|
||||
@@ -13,12 +42,14 @@ export interface ExecutionWorkspaceStrategy {
|
||||
|
||||
export interface ProjectExecutionWorkspacePolicy {
|
||||
enabled: boolean;
|
||||
defaultMode?: "project_primary" | "isolated";
|
||||
defaultMode?: ProjectExecutionWorkspaceDefaultMode;
|
||||
allowIssueOverride?: boolean;
|
||||
defaultProjectWorkspaceId?: string | null;
|
||||
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
|
||||
workspaceRuntime?: Record<string, unknown> | null;
|
||||
branchPolicy?: Record<string, unknown> | null;
|
||||
pullRequestPolicy?: Record<string, unknown> | null;
|
||||
runtimePolicy?: Record<string, unknown> | null;
|
||||
cleanupPolicy?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -28,11 +59,39 @@ export interface IssueExecutionWorkspaceSettings {
|
||||
workspaceRuntime?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspace {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId: string | null;
|
||||
sourceIssueId: string | null;
|
||||
mode: Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing" | "agent_default"> | "adapter_managed" | "cloud_sandbox";
|
||||
strategyType: ExecutionWorkspaceStrategyType;
|
||||
name: string;
|
||||
status: ExecutionWorkspaceStatus;
|
||||
cwd: string | null;
|
||||
repoUrl: string | null;
|
||||
baseRef: string | null;
|
||||
branchName: string | null;
|
||||
providerType: ExecutionWorkspaceProviderType;
|
||||
providerRef: string | null;
|
||||
derivedFromExecutionWorkspaceId: string | null;
|
||||
lastUsedAt: Date;
|
||||
openedAt: Date;
|
||||
closedAt: Date | null;
|
||||
cleanupEligibleAt: Date | null;
|
||||
cleanupReason: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeService {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
issueId: string | null;
|
||||
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId: string | null;
|
||||
|
||||
18
packages/shared/src/validators/execution-workspace.ts
Normal file
18
packages/shared/src/validators/execution-workspace.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const executionWorkspaceStatusSchema = z.enum([
|
||||
"active",
|
||||
"idle",
|
||||
"in_review",
|
||||
"archived",
|
||||
"cleanup_failed",
|
||||
]);
|
||||
|
||||
export const updateExecutionWorkspaceSchema = z.object({
|
||||
status: executionWorkspaceStatusSchema.optional(),
|
||||
cleanupEligibleAt: z.string().datetime().optional().nullable(),
|
||||
cleanupReason: z.string().optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
}).strict();
|
||||
|
||||
export type UpdateExecutionWorkspace = z.infer<typeof updateExecutionWorkspaceSchema>;
|
||||
@@ -76,6 +76,22 @@ export {
|
||||
type CreateIssueAttachmentMetadata,
|
||||
} from "./issue.js";
|
||||
|
||||
export {
|
||||
createIssueWorkProductSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
issueWorkProductTypeSchema,
|
||||
issueWorkProductStatusSchema,
|
||||
issueWorkProductReviewStateSchema,
|
||||
type CreateIssueWorkProduct,
|
||||
type UpdateIssueWorkProduct,
|
||||
} from "./work-product.js";
|
||||
|
||||
export {
|
||||
updateExecutionWorkspaceSchema,
|
||||
executionWorkspaceStatusSchema,
|
||||
type UpdateExecutionWorkspace,
|
||||
} from "./execution-workspace.js";
|
||||
|
||||
export {
|
||||
createGoalSchema,
|
||||
updateGoalSchema,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
|
||||
|
||||
const executionWorkspaceStrategySchema = z
|
||||
.object({
|
||||
type: z.enum(["project_primary", "git_worktree"]).optional(),
|
||||
type: z.enum(["project_primary", "git_worktree", "adapter_managed", "cloud_sandbox"]).optional(),
|
||||
baseRef: z.string().optional().nullable(),
|
||||
branchTemplate: z.string().optional().nullable(),
|
||||
worktreeParentDir: z.string().optional().nullable(),
|
||||
@@ -14,7 +14,7 @@ const executionWorkspaceStrategySchema = z
|
||||
|
||||
export const issueExecutionWorkspaceSettingsSchema = z
|
||||
.object({
|
||||
mode: z.enum(["inherit", "project_primary", "isolated", "agent_default"]).optional(),
|
||||
mode: z.enum(["inherit", "shared_workspace", "isolated_workspace", "operator_branch", "reuse_existing", "agent_default"]).optional(),
|
||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
})
|
||||
@@ -29,6 +29,7 @@ export const issueAssigneeAdapterOverridesSchema = z
|
||||
|
||||
export const createIssueSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
projectWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
goalId: z.string().uuid().optional().nullable(),
|
||||
parentId: z.string().uuid().optional().nullable(),
|
||||
title: z.string().min(1),
|
||||
@@ -40,6 +41,15 @@ export const createIssueSchema = z.object({
|
||||
requestDepth: z.number().int().nonnegative().optional().default(0),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
executionWorkspacePreference: z.enum([
|
||||
"inherit",
|
||||
"shared_workspace",
|
||||
"isolated_workspace",
|
||||
"operator_branch",
|
||||
"reuse_existing",
|
||||
"agent_default",
|
||||
]).optional().nullable(),
|
||||
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
||||
labelIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PROJECT_STATUSES } from "../constants.js";
|
||||
|
||||
const executionWorkspaceStrategySchema = z
|
||||
.object({
|
||||
type: z.enum(["project_primary", "git_worktree"]).optional(),
|
||||
type: z.enum(["project_primary", "git_worktree", "adapter_managed", "cloud_sandbox"]).optional(),
|
||||
baseRef: z.string().optional().nullable(),
|
||||
branchTemplate: z.string().optional().nullable(),
|
||||
worktreeParentDir: z.string().optional().nullable(),
|
||||
@@ -15,30 +15,54 @@ const executionWorkspaceStrategySchema = z
|
||||
export const projectExecutionWorkspacePolicySchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
defaultMode: z.enum(["project_primary", "isolated"]).optional(),
|
||||
defaultMode: z.enum(["shared_workspace", "isolated_workspace", "operator_branch", "adapter_default"]).optional(),
|
||||
allowIssueOverride: z.boolean().optional(),
|
||||
defaultProjectWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
branchPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
pullRequestPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
runtimePolicy: z.record(z.unknown()).optional().nullable(),
|
||||
cleanupPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]);
|
||||
const projectWorkspaceVisibilitySchema = z.enum(["default", "advanced"]);
|
||||
|
||||
const projectWorkspaceFields = {
|
||||
name: z.string().min(1).optional(),
|
||||
sourceType: projectWorkspaceSourceTypeSchema.optional(),
|
||||
cwd: z.string().min(1).optional().nullable(),
|
||||
repoUrl: z.string().url().optional().nullable(),
|
||||
repoRef: z.string().optional().nullable(),
|
||||
defaultRef: z.string().optional().nullable(),
|
||||
visibility: projectWorkspaceVisibilitySchema.optional(),
|
||||
setupCommand: z.string().optional().nullable(),
|
||||
cleanupCommand: z.string().optional().nullable(),
|
||||
remoteProvider: z.string().optional().nullable(),
|
||||
remoteWorkspaceRef: z.string().optional().nullable(),
|
||||
sharedWorkspaceKey: z.string().optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
};
|
||||
|
||||
export const createProjectWorkspaceSchema = z.object({
|
||||
...projectWorkspaceFields,
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
}).superRefine((value, ctx) => {
|
||||
function validateProjectWorkspace(value: Record<string, unknown>, ctx: z.RefinementCtx) {
|
||||
const sourceType = value.sourceType ?? "local_path";
|
||||
const hasCwd = typeof value.cwd === "string" && value.cwd.trim().length > 0;
|
||||
const hasRepo = typeof value.repoUrl === "string" && value.repoUrl.trim().length > 0;
|
||||
const hasRemoteRef = typeof value.remoteWorkspaceRef === "string" && value.remoteWorkspaceRef.trim().length > 0;
|
||||
|
||||
if (sourceType === "remote_managed") {
|
||||
if (!hasRemoteRef && !hasRepo) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Remote-managed workspace requires remoteWorkspaceRef or repoUrl.",
|
||||
path: ["remoteWorkspaceRef"],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasCwd && !hasRepo) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -46,7 +70,12 @@ export const createProjectWorkspaceSchema = z.object({
|
||||
path: ["cwd"],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const createProjectWorkspaceSchema = z.object({
|
||||
...projectWorkspaceFields,
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
}).superRefine(validateProjectWorkspace);
|
||||
|
||||
export type CreateProjectWorkspace = z.infer<typeof createProjectWorkspaceSchema>;
|
||||
|
||||
|
||||
54
packages/shared/src/validators/work-product.ts
Normal file
54
packages/shared/src/validators/work-product.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const issueWorkProductTypeSchema = z.enum([
|
||||
"preview_url",
|
||||
"runtime_service",
|
||||
"pull_request",
|
||||
"branch",
|
||||
"commit",
|
||||
"artifact",
|
||||
"document",
|
||||
]);
|
||||
|
||||
export const issueWorkProductStatusSchema = z.enum([
|
||||
"active",
|
||||
"ready_for_review",
|
||||
"approved",
|
||||
"changes_requested",
|
||||
"merged",
|
||||
"closed",
|
||||
"failed",
|
||||
"archived",
|
||||
"draft",
|
||||
]);
|
||||
|
||||
export const issueWorkProductReviewStateSchema = z.enum([
|
||||
"none",
|
||||
"needs_board_review",
|
||||
"approved",
|
||||
"changes_requested",
|
||||
]);
|
||||
|
||||
export const createIssueWorkProductSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
runtimeServiceId: z.string().uuid().optional().nullable(),
|
||||
type: issueWorkProductTypeSchema,
|
||||
provider: z.string().min(1),
|
||||
externalId: z.string().optional().nullable(),
|
||||
title: z.string().min(1),
|
||||
url: z.string().url().optional().nullable(),
|
||||
status: issueWorkProductStatusSchema.default("active"),
|
||||
reviewState: issueWorkProductReviewStateSchema.optional().default("none"),
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"),
|
||||
summary: z.string().optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
createdByRunId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateIssueWorkProduct = z.infer<typeof createIssueWorkProductSchema>;
|
||||
|
||||
export const updateIssueWorkProductSchema = createIssueWorkProductSchema.partial();
|
||||
|
||||
export type UpdateIssueWorkProduct = z.infer<typeof updateIssueWorkProductSchema>;
|
||||
@@ -12,36 +12,36 @@ describe("execution workspace policy helpers", () => {
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
defaultMode: "isolated_workspace",
|
||||
}),
|
||||
).toEqual({ mode: "isolated" });
|
||||
).toEqual({ mode: "isolated_workspace" });
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
enabled: true,
|
||||
defaultMode: "project_primary",
|
||||
defaultMode: "shared_workspace",
|
||||
}),
|
||||
).toEqual({ mode: "project_primary" });
|
||||
).toEqual({ mode: "shared_workspace" });
|
||||
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers explicit issue mode over project policy and legacy overrides", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: { enabled: true, defaultMode: "project_primary" },
|
||||
issueSettings: { mode: "isolated" },
|
||||
projectPolicy: { enabled: true, defaultMode: "shared_workspace" },
|
||||
issueSettings: { mode: "isolated_workspace" },
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("isolated");
|
||||
).toBe("isolated_workspace");
|
||||
});
|
||||
|
||||
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated" },
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
||||
issueSettings: null,
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("isolated");
|
||||
).toBe("isolated_workspace");
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: null,
|
||||
@@ -58,7 +58,7 @@ describe("execution workspace policy helpers", () => {
|
||||
},
|
||||
projectPolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
defaultMode: "isolated_workspace",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
baseRef: "origin/main",
|
||||
@@ -69,7 +69,7 @@ describe("execution workspace policy helpers", () => {
|
||||
},
|
||||
},
|
||||
issueSettings: null,
|
||||
mode: "isolated",
|
||||
mode: "isolated_workspace",
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
|
||||
@@ -92,9 +92,9 @@ describe("execution workspace policy helpers", () => {
|
||||
expect(
|
||||
buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: baseConfig,
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated" },
|
||||
issueSettings: { mode: "project_primary" },
|
||||
mode: "project_primary",
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
||||
issueSettings: { mode: "shared_workspace" },
|
||||
mode: "shared_workspace",
|
||||
legacyUseProjectWorkspace: null,
|
||||
}).workspaceStrategy,
|
||||
).toBeUndefined();
|
||||
@@ -124,7 +124,7 @@ describe("execution workspace policy helpers", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
defaultMode: "isolated_workspace",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
@@ -137,7 +137,7 @@ describe("execution workspace policy helpers", () => {
|
||||
mode: "project_primary",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "project_primary",
|
||||
mode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { companyRoutes } from "./routes/companies.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { projectRoutes } from "./routes/projects.js";
|
||||
import { issueRoutes } from "./routes/issues.js";
|
||||
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
||||
import { goalRoutes } from "./routes/goals.js";
|
||||
import { approvalRoutes } from "./routes/approvals.js";
|
||||
import { secretRoutes } from "./routes/secrets.js";
|
||||
@@ -107,6 +108,7 @@ export async function createApp(
|
||||
api.use(assetRoutes(db, opts.storageService));
|
||||
api.use(projectRoutes(db));
|
||||
api.use(issueRoutes(db, opts.storageService));
|
||||
api.use(executionWorkspaceRoutes(db));
|
||||
api.use(goalRoutes(db));
|
||||
api.use(approvalRoutes(db));
|
||||
api.use(secretRoutes(db));
|
||||
|
||||
68
server/src/routes/execution-workspaces.ts
Normal file
68
server/src/routes/execution-workspaces.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { executionWorkspaceService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function executionWorkspaceRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = executionWorkspaceService(db);
|
||||
|
||||
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const workspaces = await svc.list(companyId, {
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
|
||||
issueId: req.query.issueId as string | undefined,
|
||||
status: req.query.status as string | undefined,
|
||||
reuseEligible: req.query.reuseEligible === "true",
|
||||
});
|
||||
res.json(workspaces);
|
||||
});
|
||||
|
||||
router.get("/execution-workspaces/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workspace = await svc.getById(id);
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, workspace.companyId);
|
||||
res.json(workspace);
|
||||
});
|
||||
|
||||
router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const workspace = await svc.update(id, {
|
||||
...req.body,
|
||||
...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}),
|
||||
});
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Execution workspace not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "execution_workspace.updated",
|
||||
entityType: "execution_workspace",
|
||||
entityId: workspace.id,
|
||||
details: { changedKeys: Object.keys(req.body).sort() },
|
||||
});
|
||||
res.json(workspace);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
createIssueWorkProductSchema,
|
||||
createIssueLabelSchema,
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
linkIssueApprovalSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
updateIssueSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
@@ -15,12 +17,14 @@ import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
executionWorkspaceService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
logActivity,
|
||||
projectService,
|
||||
workProductService,
|
||||
} from "../services/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
@@ -37,6 +41,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const projectsSvc = projectService(db);
|
||||
const goalsSvc = goalService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||
const workProductsSvc = workProductService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
@@ -304,6 +310,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
const currentExecutionWorkspace = issue.executionWorkspaceId
|
||||
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
||||
: null;
|
||||
const workProducts = await workProductsSvc.listForIssue(issue.id);
|
||||
res.json({
|
||||
...issue,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
@@ -311,9 +321,110 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
project: project ?? null,
|
||||
goal: goal ?? null,
|
||||
mentionedProjects,
|
||||
currentExecutionWorkspace,
|
||||
workProducts,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/work-products", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const workProducts = await workProductsSvc.listForIssue(issue.id);
|
||||
res.json(workProducts);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/work-products", validate(createIssueWorkProductSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, {
|
||||
...req.body,
|
||||
projectId: req.body.projectId ?? issue.projectId ?? null,
|
||||
});
|
||||
if (!product) {
|
||||
res.status(422).json({ error: "Invalid work product payload" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.work_product_created",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: { workProductId: product.id, type: product.type, provider: product.provider },
|
||||
});
|
||||
res.status(201).json(product);
|
||||
});
|
||||
|
||||
router.patch("/work-products/:id", validate(updateIssueWorkProductSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await workProductsSvc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const product = await workProductsSvc.update(id, req.body);
|
||||
if (!product) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.work_product_updated",
|
||||
entityType: "issue",
|
||||
entityId: existing.issueId,
|
||||
details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
|
||||
});
|
||||
res.json(product);
|
||||
});
|
||||
|
||||
router.delete("/work-products/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await workProductsSvc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const removed = await workProductsSvc.remove(id);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.work_product_deleted",
|
||||
entityType: "issue",
|
||||
entityId: existing.issueId,
|
||||
details: { workProductId: removed.id, type: removed.type },
|
||||
});
|
||||
res.json(removed);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/read", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
||||
@@ -2,11 +2,12 @@ import type {
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
ProjectExecutionWorkspaceDefaultMode,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { asString, parseObject } from "../adapters/utils.js";
|
||||
|
||||
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit">;
|
||||
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
|
||||
|
||||
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
@@ -16,7 +17,7 @@ function cloneRecord(value: Record<string, unknown> | null | undefined): Record<
|
||||
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
|
||||
const parsed = parseObject(raw);
|
||||
const type = asString(parsed.type, "");
|
||||
if (type !== "project_primary" && type !== "git_worktree") {
|
||||
if (type !== "project_primary" && type !== "git_worktree" && type !== "adapter_managed" && type !== "cloud_sandbox") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -34,12 +35,28 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
|
||||
const defaultMode = asString(parsed.defaultMode, "");
|
||||
const defaultProjectWorkspaceId =
|
||||
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
|
||||
const allowIssueOverride =
|
||||
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
|
||||
const normalizedDefaultMode = (() => {
|
||||
if (
|
||||
defaultMode === "shared_workspace" ||
|
||||
defaultMode === "isolated_workspace" ||
|
||||
defaultMode === "operator_branch" ||
|
||||
defaultMode === "adapter_default"
|
||||
) {
|
||||
return defaultMode as ProjectExecutionWorkspaceDefaultMode;
|
||||
}
|
||||
if (defaultMode === "project_primary") return "shared_workspace";
|
||||
if (defaultMode === "isolated") return "isolated_workspace";
|
||||
return undefined;
|
||||
})();
|
||||
return {
|
||||
enabled,
|
||||
...(defaultMode === "project_primary" || defaultMode === "isolated" ? { defaultMode } : {}),
|
||||
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
|
||||
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
|
||||
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
|
||||
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
|
||||
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
|
||||
: {}),
|
||||
@@ -52,6 +69,9 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
||||
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
|
||||
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.runtimePolicy && typeof parsed.runtimePolicy === "object" && !Array.isArray(parsed.runtimePolicy)
|
||||
? { runtimePolicy: { ...(parsed.runtimePolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
|
||||
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
@@ -62,9 +82,24 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
||||
const parsed = parseObject(raw);
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
const mode = asString(parsed.mode, "");
|
||||
const normalizedMode = (() => {
|
||||
if (
|
||||
mode === "inherit" ||
|
||||
mode === "shared_workspace" ||
|
||||
mode === "isolated_workspace" ||
|
||||
mode === "operator_branch" ||
|
||||
mode === "reuse_existing" ||
|
||||
mode === "agent_default"
|
||||
) {
|
||||
return mode;
|
||||
}
|
||||
if (mode === "project_primary") return "shared_workspace";
|
||||
if (mode === "isolated") return "isolated_workspace";
|
||||
return "";
|
||||
})();
|
||||
return {
|
||||
...(mode === "inherit" || mode === "project_primary" || mode === "isolated" || mode === "agent_default"
|
||||
? { mode }
|
||||
...(normalizedMode
|
||||
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
|
||||
: {}),
|
||||
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
|
||||
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
|
||||
@@ -80,7 +115,14 @@ export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
): IssueExecutionWorkspaceSettings | null {
|
||||
if (!projectPolicy?.enabled) return null;
|
||||
return {
|
||||
mode: projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary",
|
||||
mode:
|
||||
projectPolicy.defaultMode === "isolated_workspace"
|
||||
? "isolated_workspace"
|
||||
: projectPolicy.defaultMode === "operator_branch"
|
||||
? "operator_branch"
|
||||
: projectPolicy.defaultMode === "adapter_default"
|
||||
? "agent_default"
|
||||
: "shared_workspace",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,16 +132,19 @@ export function resolveExecutionWorkspaceMode(input: {
|
||||
legacyUseProjectWorkspace: boolean | null;
|
||||
}): ParsedExecutionWorkspaceMode {
|
||||
const issueMode = input.issueSettings?.mode;
|
||||
if (issueMode && issueMode !== "inherit") {
|
||||
if (issueMode && issueMode !== "inherit" && issueMode !== "reuse_existing") {
|
||||
return issueMode;
|
||||
}
|
||||
if (input.projectPolicy?.enabled) {
|
||||
return input.projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary";
|
||||
if (input.projectPolicy.defaultMode === "isolated_workspace") return "isolated_workspace";
|
||||
if (input.projectPolicy.defaultMode === "operator_branch") return "operator_branch";
|
||||
if (input.projectPolicy.defaultMode === "adapter_default") return "agent_default";
|
||||
return "shared_workspace";
|
||||
}
|
||||
if (input.legacyUseProjectWorkspace === false) {
|
||||
return "agent_default";
|
||||
}
|
||||
return "project_primary";
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
export function buildExecutionWorkspaceAdapterConfig(input: {
|
||||
@@ -119,7 +164,7 @@ export function buildExecutionWorkspaceAdapterConfig(input: {
|
||||
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
|
||||
|
||||
if (hasWorkspaceControl) {
|
||||
if (input.mode === "isolated") {
|
||||
if (input.mode === "isolated_workspace") {
|
||||
const strategy =
|
||||
input.issueSettings?.workspaceStrategy ??
|
||||
input.projectPolicy?.workspaceStrategy ??
|
||||
|
||||
99
server/src/services/execution-workspaces.ts
Normal file
99
server/src/services/execution-workspaces.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { executionWorkspaces } from "@paperclipai/db";
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
|
||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||
|
||||
function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
sourceIssueId: row.sourceIssueId ?? null,
|
||||
mode: row.mode as ExecutionWorkspace["mode"],
|
||||
strategyType: row.strategyType as ExecutionWorkspace["strategyType"],
|
||||
name: row.name,
|
||||
status: row.status as ExecutionWorkspace["status"],
|
||||
cwd: row.cwd ?? null,
|
||||
repoUrl: row.repoUrl ?? null,
|
||||
baseRef: row.baseRef ?? null,
|
||||
branchName: row.branchName ?? null,
|
||||
providerType: row.providerType as ExecutionWorkspace["providerType"],
|
||||
providerRef: row.providerRef ?? null,
|
||||
derivedFromExecutionWorkspaceId: row.derivedFromExecutionWorkspaceId ?? null,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
openedAt: row.openedAt,
|
||||
closedAt: row.closedAt ?? null,
|
||||
cleanupEligibleAt: row.cleanupEligibleAt ?? null,
|
||||
cleanupReason: row.cleanupReason ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function executionWorkspaceService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, filters?: {
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
}) => {
|
||||
const conditions = [eq(executionWorkspaces.companyId, companyId)];
|
||||
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
|
||||
if (filters?.projectWorkspaceId) {
|
||||
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
|
||||
}
|
||||
if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId));
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean);
|
||||
if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!));
|
||||
else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses));
|
||||
}
|
||||
if (filters?.reuseEligible) {
|
||||
conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(executionWorkspaces)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||
return rows.map(toExecutionWorkspace);
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
},
|
||||
|
||||
create: async (data: typeof executionWorkspaces.$inferInsert) => {
|
||||
const row = await db
|
||||
.insert(executionWorkspaces)
|
||||
.values(data)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
},
|
||||
|
||||
update: async (id: string, patch: Partial<typeof executionWorkspaces.$inferInsert>) => {
|
||||
const row = await db
|
||||
.update(executionWorkspaces)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { toExecutionWorkspace };
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
releaseRuntimeServicesForRun,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
@@ -455,6 +456,7 @@ export function heartbeatService(db: Db) {
|
||||
const runLogStore = getRunLogStore();
|
||||
const secretsSvc = secretService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||
const activeRunExecutions = new Set<string>();
|
||||
|
||||
async function getAgent(agentId: string) {
|
||||
@@ -1130,6 +1132,9 @@ export function heartbeatService(db: Db) {
|
||||
? await db
|
||||
.select({
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
@@ -1197,6 +1202,10 @@ export function heartbeatService(db: Db) {
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||
@@ -1219,6 +1228,67 @@ export function heartbeatService(db: Db) {
|
||||
companyId: agent.companyId,
|
||||
},
|
||||
});
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
|
||||
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||
const shouldReuseExisting =
|
||||
issueRef?.executionWorkspacePreference === "reuse_existing" &&
|
||||
existingExecutionWorkspace &&
|
||||
existingExecutionWorkspace.status !== "archived";
|
||||
const persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
|
||||
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
|
||||
cwd: executionWorkspace.cwd,
|
||||
repoUrl: executionWorkspace.repoUrl,
|
||||
baseRef: executionWorkspace.repoRef,
|
||||
branchName: executionWorkspace.branchName,
|
||||
providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs",
|
||||
providerRef: executionWorkspace.worktreePath,
|
||||
status: "active",
|
||||
lastUsedAt: new Date(),
|
||||
metadata: {
|
||||
...(existingExecutionWorkspace.metadata ?? {}),
|
||||
source: executionWorkspace.source,
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
},
|
||||
})
|
||||
: resolvedProjectId
|
||||
? await executionWorkspacesSvc.create({
|
||||
companyId: agent.companyId,
|
||||
projectId: resolvedProjectId,
|
||||
projectWorkspaceId: resolvedProjectWorkspaceId,
|
||||
sourceIssueId: issueRef?.id ?? null,
|
||||
mode:
|
||||
executionWorkspaceMode === "isolated_workspace"
|
||||
? "isolated_workspace"
|
||||
: executionWorkspaceMode === "operator_branch"
|
||||
? "operator_branch"
|
||||
: executionWorkspaceMode === "agent_default"
|
||||
? "adapter_managed"
|
||||
: "shared_workspace",
|
||||
strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary",
|
||||
name: executionWorkspace.branchName ?? issueRef?.identifier ?? `workspace-${agent.id.slice(0, 8)}`,
|
||||
status: "active",
|
||||
cwd: executionWorkspace.cwd,
|
||||
repoUrl: executionWorkspace.repoUrl,
|
||||
baseRef: executionWorkspace.repoRef,
|
||||
branchName: executionWorkspace.branchName,
|
||||
providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs",
|
||||
providerRef: executionWorkspace.worktreePath,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
metadata: {
|
||||
source: executionWorkspace.source,
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
|
||||
await issuesSvc.update(issueId, {
|
||||
executionWorkspaceId: persistedExecutionWorkspace.id,
|
||||
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}),
|
||||
});
|
||||
}
|
||||
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
|
||||
agentId: agent.id,
|
||||
previousSessionParams,
|
||||
|
||||
@@ -14,6 +14,8 @@ export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
export { workProductService } from "./work-products.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
companyMemberships,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
executionWorkspaces,
|
||||
issueAttachments,
|
||||
issueLabels,
|
||||
issueComments,
|
||||
@@ -353,6 +354,40 @@ export function issueService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertValidProjectWorkspace(companyId: string, projectId: string | null | undefined, projectWorkspaceId: string) {
|
||||
const workspace = await db
|
||||
.select({
|
||||
id: projectWorkspaces.id,
|
||||
companyId: projectWorkspaces.companyId,
|
||||
projectId: projectWorkspaces.projectId,
|
||||
})
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.id, projectWorkspaceId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!workspace) throw notFound("Project workspace not found");
|
||||
if (workspace.companyId !== companyId) throw unprocessable("Project workspace must belong to same company");
|
||||
if (projectId && workspace.projectId !== projectId) {
|
||||
throw unprocessable("Project workspace must belong to the selected project");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertValidExecutionWorkspace(companyId: string, projectId: string | null | undefined, executionWorkspaceId: string) {
|
||||
const workspace = await db
|
||||
.select({
|
||||
id: executionWorkspaces.id,
|
||||
companyId: executionWorkspaces.companyId,
|
||||
projectId: executionWorkspaces.projectId,
|
||||
})
|
||||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, executionWorkspaceId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!workspace) throw notFound("Execution workspace not found");
|
||||
if (workspace.companyId !== companyId) throw unprocessable("Execution workspace must belong to same company");
|
||||
if (projectId && workspace.projectId !== projectId) {
|
||||
throw unprocessable("Execution workspace must belong to the selected project");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) {
|
||||
if (labelIds.length === 0) return;
|
||||
const existing = await dbOrTx
|
||||
@@ -647,6 +682,12 @@ export function issueService(db: Db) {
|
||||
if (data.assigneeUserId) {
|
||||
await assertAssignableUser(companyId, data.assigneeUserId);
|
||||
}
|
||||
if (data.projectWorkspaceId) {
|
||||
await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId);
|
||||
}
|
||||
if (data.executionWorkspaceId) {
|
||||
await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId);
|
||||
}
|
||||
if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) {
|
||||
throw unprocessable("in_progress issues require an assignee");
|
||||
}
|
||||
@@ -665,6 +706,26 @@ export function issueService(db: Db) {
|
||||
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
|
||||
) as Record<string, unknown> | null;
|
||||
}
|
||||
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
|
||||
if (!projectWorkspaceId && issueData.projectId) {
|
||||
const project = await tx
|
||||
.select({
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const projectPolicy = parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy);
|
||||
projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null;
|
||||
if (!projectWorkspaceId) {
|
||||
projectWorkspaceId = await tx
|
||||
.select({ id: projectWorkspaces.id })
|
||||
.from(projectWorkspaces)
|
||||
.where(and(eq(projectWorkspaces.projectId, issueData.projectId), eq(projectWorkspaces.companyId, companyId)))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||
.then((rows) => rows[0]?.id ?? null);
|
||||
}
|
||||
}
|
||||
const [company] = await tx
|
||||
.update(companies)
|
||||
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
|
||||
@@ -681,6 +742,7 @@ export function issueService(db: Db) {
|
||||
goalId: issueData.goalId,
|
||||
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
||||
}),
|
||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
companyId,
|
||||
issueNumber,
|
||||
@@ -741,6 +803,17 @@ export function issueService(db: Db) {
|
||||
if (issueData.assigneeUserId) {
|
||||
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
|
||||
}
|
||||
const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId;
|
||||
const nextProjectWorkspaceId =
|
||||
issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId;
|
||||
const nextExecutionWorkspaceId =
|
||||
issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId;
|
||||
if (nextProjectWorkspaceId) {
|
||||
await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId);
|
||||
}
|
||||
if (nextExecutionWorkspaceId) {
|
||||
await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
|
||||
}
|
||||
|
||||
applyStatusSideEffects(issueData.status, patch);
|
||||
if (issueData.status && issueData.status !== "done") {
|
||||
|
||||
@@ -20,9 +20,17 @@ type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
type CreateWorkspaceInput = {
|
||||
name?: string | null;
|
||||
sourceType?: string | null;
|
||||
cwd?: string | null;
|
||||
repoUrl?: string | null;
|
||||
repoRef?: string | null;
|
||||
defaultRef?: string | null;
|
||||
visibility?: string | null;
|
||||
setupCommand?: string | null;
|
||||
cleanupCommand?: string | null;
|
||||
remoteProvider?: string | null;
|
||||
remoteWorkspaceRef?: string | null;
|
||||
sharedWorkspaceKey?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
@@ -91,6 +99,7 @@ function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeServ
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId ?? null,
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
issueId: row.issueId ?? null,
|
||||
scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"],
|
||||
scopeId: row.scopeId ?? null,
|
||||
@@ -125,9 +134,17 @@ function toWorkspace(
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
name: row.name,
|
||||
sourceType: row.sourceType as ProjectWorkspace["sourceType"],
|
||||
cwd: row.cwd,
|
||||
repoUrl: row.repoUrl ?? null,
|
||||
repoRef: row.repoRef ?? null,
|
||||
defaultRef: row.defaultRef ?? row.repoRef ?? null,
|
||||
visibility: row.visibility as ProjectWorkspace["visibility"],
|
||||
setupCommand: row.setupCommand ?? null,
|
||||
cleanupCommand: row.cleanupCommand ?? null,
|
||||
remoteProvider: row.remoteProvider ?? null,
|
||||
remoteWorkspaceRef: row.remoteWorkspaceRef ?? null,
|
||||
sharedWorkspaceKey: row.sharedWorkspaceKey ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
isPrimary: row.isPrimary,
|
||||
runtimeServices,
|
||||
@@ -491,7 +508,13 @@ export function projectService(db: Db) {
|
||||
|
||||
const cwd = normalizeWorkspaceCwd(data.cwd);
|
||||
const repoUrl = readNonEmptyString(data.repoUrl);
|
||||
if (!cwd && !repoUrl) return null;
|
||||
const sourceType = readNonEmptyString(data.sourceType) ?? (repoUrl ? "git_repo" : cwd ? "local_path" : "remote_managed");
|
||||
const remoteWorkspaceRef = readNonEmptyString(data.remoteWorkspaceRef);
|
||||
if (sourceType === "remote_managed") {
|
||||
if (!remoteWorkspaceRef && !repoUrl) return null;
|
||||
} else if (!cwd && !repoUrl) {
|
||||
return null;
|
||||
}
|
||||
const name = deriveWorkspaceName({
|
||||
name: data.name,
|
||||
cwd,
|
||||
@@ -525,9 +548,17 @@ export function projectService(db: Db) {
|
||||
companyId: project.companyId,
|
||||
projectId,
|
||||
name,
|
||||
sourceType,
|
||||
cwd: cwd ?? null,
|
||||
repoUrl: repoUrl ?? null,
|
||||
repoRef: readNonEmptyString(data.repoRef),
|
||||
defaultRef: readNonEmptyString(data.defaultRef) ?? readNonEmptyString(data.repoRef),
|
||||
visibility: readNonEmptyString(data.visibility) ?? "default",
|
||||
setupCommand: readNonEmptyString(data.setupCommand),
|
||||
cleanupCommand: readNonEmptyString(data.cleanupCommand),
|
||||
remoteProvider: readNonEmptyString(data.remoteProvider),
|
||||
remoteWorkspaceRef,
|
||||
sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey),
|
||||
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||
isPrimary: shouldBePrimary,
|
||||
})
|
||||
@@ -564,7 +595,19 @@ export function projectService(db: Db) {
|
||||
data.repoUrl !== undefined
|
||||
? readNonEmptyString(data.repoUrl)
|
||||
: readNonEmptyString(existing.repoUrl);
|
||||
if (!nextCwd && !nextRepoUrl) return null;
|
||||
const nextSourceType =
|
||||
data.sourceType !== undefined
|
||||
? readNonEmptyString(data.sourceType)
|
||||
: readNonEmptyString(existing.sourceType);
|
||||
const nextRemoteWorkspaceRef =
|
||||
data.remoteWorkspaceRef !== undefined
|
||||
? readNonEmptyString(data.remoteWorkspaceRef)
|
||||
: readNonEmptyString(existing.remoteWorkspaceRef);
|
||||
if (nextSourceType === "remote_managed") {
|
||||
if (!nextRemoteWorkspaceRef && !nextRepoUrl) return null;
|
||||
} else if (!nextCwd && !nextRepoUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patch: Partial<typeof projectWorkspaces.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
@@ -576,6 +619,16 @@ export function projectService(db: Db) {
|
||||
if (data.cwd !== undefined) patch.cwd = nextCwd ?? null;
|
||||
if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null;
|
||||
if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(data.repoRef);
|
||||
if (data.sourceType !== undefined && nextSourceType) patch.sourceType = nextSourceType;
|
||||
if (data.defaultRef !== undefined) patch.defaultRef = readNonEmptyString(data.defaultRef);
|
||||
if (data.visibility !== undefined && readNonEmptyString(data.visibility)) {
|
||||
patch.visibility = readNonEmptyString(data.visibility)!;
|
||||
}
|
||||
if (data.setupCommand !== undefined) patch.setupCommand = readNonEmptyString(data.setupCommand);
|
||||
if (data.cleanupCommand !== undefined) patch.cleanupCommand = readNonEmptyString(data.cleanupCommand);
|
||||
if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider);
|
||||
if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef;
|
||||
if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey);
|
||||
if (data.metadata !== undefined) patch.metadata = data.metadata;
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
|
||||
113
server/src/services/work-products.ts
Normal file
113
server/src/services/work-products.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issueWorkProducts } from "@paperclipai/db";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
|
||||
type IssueWorkProductRow = typeof issueWorkProducts.$inferSelect;
|
||||
|
||||
function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId ?? null,
|
||||
issueId: row.issueId,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
runtimeServiceId: row.runtimeServiceId ?? null,
|
||||
type: row.type as IssueWorkProduct["type"],
|
||||
provider: row.provider,
|
||||
externalId: row.externalId ?? null,
|
||||
title: row.title,
|
||||
url: row.url ?? null,
|
||||
status: row.status,
|
||||
reviewState: row.reviewState as IssueWorkProduct["reviewState"],
|
||||
isPrimary: row.isPrimary,
|
||||
healthStatus: row.healthStatus as IssueWorkProduct["healthStatus"],
|
||||
summary: row.summary ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
createdByRunId: row.createdByRunId ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function workProductService(db: Db) {
|
||||
return {
|
||||
listForIssue: async (issueId: string) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(issueWorkProducts)
|
||||
.where(eq(issueWorkProducts.issueId, issueId))
|
||||
.orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt));
|
||||
return rows.map(toIssueWorkProduct);
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueWorkProducts)
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
|
||||
createForIssue: async (issueId: string, companyId: string, data: Omit<typeof issueWorkProducts.$inferInsert, "issueId" | "companyId">) => {
|
||||
if (data.isPrimary) {
|
||||
await db
|
||||
.update(issueWorkProducts)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(and(eq(issueWorkProducts.companyId, companyId), eq(issueWorkProducts.issueId, issueId), eq(issueWorkProducts.type, data.type)));
|
||||
}
|
||||
const row = await db
|
||||
.insert(issueWorkProducts)
|
||||
.values({
|
||||
...data,
|
||||
companyId,
|
||||
issueId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
|
||||
update: async (id: string, patch: Partial<typeof issueWorkProducts.$inferInsert>) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(issueWorkProducts)
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
if (patch.isPrimary === true) {
|
||||
await db
|
||||
.update(issueWorkProducts)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(issueWorkProducts.companyId, existing.companyId),
|
||||
eq(issueWorkProducts.issueId, existing.issueId),
|
||||
eq(issueWorkProducts.type, existing.type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const row = await db
|
||||
.update(issueWorkProducts)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
|
||||
remove: async (id: string) => {
|
||||
const row = await db
|
||||
.delete(issueWorkProducts)
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { toIssueWorkProduct };
|
||||
@@ -14,6 +14,7 @@ import { Projects } from "./pages/Projects";
|
||||
import { ProjectDetail } from "./pages/ProjectDetail";
|
||||
import { Issues } from "./pages/Issues";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||
import { Goals } from "./pages/Goals";
|
||||
import { GoalDetail } from "./pages/GoalDetail";
|
||||
import { Approvals } from "./pages/Approvals";
|
||||
@@ -136,6 +137,7 @@ function boardRoutes() {
|
||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
||||
|
||||
26
ui/src/api/execution-workspaces.ts
Normal file
26
ui/src/api/execution-workspaces.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const executionWorkspacesApi = {
|
||||
list: (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
|
||||
if (filters?.issueId) params.set("issueId", filters.issueId);
|
||||
if (filters?.status) params.set("status", filters.status);
|
||||
if (filters?.reuseEligible) params.set("reuseEligible", "true");
|
||||
const qs = params.toString();
|
||||
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel } from "@paperclipai/shared";
|
||||
import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const issuesApi = {
|
||||
@@ -73,4 +73,10 @@ export const issuesApi = {
|
||||
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
|
||||
unlinkApproval: (id: string, approvalId: string) =>
|
||||
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
|
||||
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
|
||||
createWorkProduct: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
|
||||
updateWorkProduct: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<IssueWorkProduct>(`/work-products/${id}`, data),
|
||||
deleteWorkProduct: (id: string) => api.delete<IssueWorkProduct>(`/work-products/${id}`),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Issue } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -15,13 +16,43 @@ import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
|
||||
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
|
||||
const EXECUTION_WORKSPACE_OPTIONS = [
|
||||
{ value: "shared_workspace", label: "Project default" },
|
||||
{ value: "isolated_workspace", label: "New isolated workspace" },
|
||||
{ value: "reuse_existing", label: "Reuse existing workspace" },
|
||||
{ value: "operator_branch", label: "Operator branch" },
|
||||
{ value: "agent_default", label: "Agent default" },
|
||||
] as const;
|
||||
|
||||
function defaultProjectWorkspaceIdForProject(project: {
|
||||
workspaces?: Array<{ id: string; isPrimary: boolean }>;
|
||||
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
|
||||
} | null | undefined) {
|
||||
if (!project) return null;
|
||||
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
|
||||
?? project.workspaces?.[0]?.id
|
||||
?? null;
|
||||
}
|
||||
|
||||
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
|
||||
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
|
||||
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
|
||||
if (defaultMode === "adapter_default") return "agent_default";
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function issueModeForExistingWorkspace(mode: string | null | undefined) {
|
||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
|
||||
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
@@ -102,6 +133,7 @@ function PropertyPicker({
|
||||
|
||||
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { enabled: showExperimentalWorkspaceUi } = useExperimentalWorkspacesEnabled();
|
||||
const queryClient = useQueryClient();
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
@@ -182,15 +214,32 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
const currentProject = issue.projectId
|
||||
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
||||
: null;
|
||||
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
|
||||
const currentProjectExecutionWorkspacePolicy = showExperimentalWorkspaceUi
|
||||
? currentProject?.executionWorkspacePolicy ?? null
|
||||
: null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const usesIsolatedExecutionWorkspace = issue.executionWorkspaceSettings?.mode === "isolated"
|
||||
? true
|
||||
: issue.executionWorkspaceSettings?.mode === "project_primary"
|
||||
? false
|
||||
: currentProjectExecutionWorkspacePolicy?.defaultMode === "isolated";
|
||||
const currentProjectWorkspaces = currentProject?.workspaces ?? [];
|
||||
const currentExecutionWorkspaceSelection =
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(currentProject);
|
||||
const { data: reusableExecutionWorkspaces } = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
|
||||
projectId: issue.projectId ?? undefined,
|
||||
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
queryFn: () =>
|
||||
executionWorkspacesApi.list(companyId!, {
|
||||
projectId: issue.projectId ?? undefined,
|
||||
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
enabled: Boolean(companyId) && showExperimentalWorkspaceUi && Boolean(issue.projectId),
|
||||
});
|
||||
const selectedReusableExecutionWorkspace = (reusableExecutionWorkspaces ?? []).find(
|
||||
(workspace) => workspace.id === issue.executionWorkspaceId,
|
||||
);
|
||||
const projectLink = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
@@ -418,7 +467,13 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
!issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ projectId: null, executionWorkspaceSettings: null });
|
||||
onUpdate({
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
});
|
||||
setProjectOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -438,10 +493,14 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
p.id === issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
|
||||
onUpdate({
|
||||
projectId: p.id,
|
||||
executionWorkspaceSettings: SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && p.executionWorkspacePolicy?.enabled
|
||||
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
|
||||
projectWorkspaceId: showExperimentalWorkspaceUi ? defaultProjectWorkspaceIdForProject(p) : null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: showExperimentalWorkspaceUi ? defaultMode : null,
|
||||
executionWorkspaceSettings: showExperimentalWorkspaceUi && p.executionWorkspacePolicy?.enabled
|
||||
? { mode: defaultMode }
|
||||
: null,
|
||||
});
|
||||
setProjectOpen(false);
|
||||
@@ -530,38 +589,94 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
{projectContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{currentProjectSupportsExecutionWorkspace && (
|
||||
{showExperimentalWorkspaceUi && currentProjectWorkspaces.length > 0 && (
|
||||
<PropertyRow label="Codebase">
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={issue.projectWorkspaceId ?? ""}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
projectWorkspaceId: e.target.value || null,
|
||||
executionWorkspaceId: null,
|
||||
})}
|
||||
>
|
||||
{currentProjectWorkspaces.map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>
|
||||
{workspace.name}
|
||||
{workspace.isPrimary ? " (default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{showExperimentalWorkspaceUi && currentProjectSupportsExecutionWorkspace && (
|
||||
<PropertyRow label="Workspace">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-2 py-1.5 w-full">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm">
|
||||
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Toggle whether this issue runs in its own execution workspace.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
usesIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
<div className="w-full space-y-2">
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={currentExecutionWorkspaceSelection}
|
||||
onChange={(e) => {
|
||||
const nextMode = e.target.value;
|
||||
onUpdate({
|
||||
executionWorkspacePreference: nextMode,
|
||||
executionWorkspaceId: nextMode === "reuse_existing" ? issue.executionWorkspaceId : null,
|
||||
executionWorkspaceSettings: {
|
||||
mode: usesIsolatedExecutionWorkspace ? "project_primary" : "isolated",
|
||||
mode:
|
||||
nextMode === "reuse_existing"
|
||||
? issueModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
|
||||
: nextMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
usesIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{currentExecutionWorkspaceSelection === "reuse_existing" && (
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={issue.executionWorkspaceId ?? ""}
|
||||
onChange={(e) => {
|
||||
const nextExecutionWorkspaceId = e.target.value || null;
|
||||
const nextExecutionWorkspace = (reusableExecutionWorkspaces ?? []).find(
|
||||
(workspace) => workspace.id === nextExecutionWorkspaceId,
|
||||
);
|
||||
onUpdate({
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceId: nextExecutionWorkspaceId,
|
||||
executionWorkspaceSettings: {
|
||||
mode: issueModeForExistingWorkspace(nextExecutionWorkspace?.mode),
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Choose an existing workspace</option>
|
||||
{(reusableExecutionWorkspaces ?? []).map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>
|
||||
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{issue.currentExecutionWorkspace && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Current:{" "}
|
||||
<Link
|
||||
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
{issue.currentExecutionWorkspace.name}
|
||||
</Link>
|
||||
{" · "}
|
||||
{issue.currentExecutionWorkspace.status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } f
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -42,11 +43,10 @@ import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDe
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
|
||||
|
||||
const DRAFT_KEY = "paperclip:issue-draft";
|
||||
const DEBOUNCE_MS = 800;
|
||||
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
|
||||
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
|
||||
|
||||
/** Return black or white hex based on background luminance (WCAG perceptual weights). */
|
||||
function getContrastTextColor(hexColor: string): string {
|
||||
@@ -65,10 +65,13 @@ interface IssueDraft {
|
||||
priority: string;
|
||||
assigneeId: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId?: string;
|
||||
assigneeModelOverride: string;
|
||||
assigneeThinkingEffort: string;
|
||||
assigneeChrome: boolean;
|
||||
useIsolatedExecutionWorkspace: boolean;
|
||||
executionWorkspaceMode?: string;
|
||||
selectedExecutionWorkspaceId?: string;
|
||||
useIsolatedExecutionWorkspace?: boolean;
|
||||
}
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||
@@ -165,9 +168,48 @@ const priorities = [
|
||||
{ value: "low", label: "Low", icon: ArrowDown, color: priorityColor.low ?? priorityColorDefault },
|
||||
];
|
||||
|
||||
const EXECUTION_WORKSPACE_MODES = [
|
||||
{ value: "shared_workspace", label: "Project default" },
|
||||
{ value: "isolated_workspace", label: "New isolated workspace" },
|
||||
{ value: "reuse_existing", label: "Reuse existing workspace" },
|
||||
{ value: "operator_branch", label: "Operator branch" },
|
||||
{ value: "agent_default", label: "Agent default" },
|
||||
] as const;
|
||||
|
||||
function defaultProjectWorkspaceIdForProject(project: { workspaces?: Array<{ id: string; isPrimary: boolean }>; executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null } | null | undefined) {
|
||||
if (!project) return "";
|
||||
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
|
||||
?? project.workspaces?.[0]?.id
|
||||
?? "";
|
||||
}
|
||||
|
||||
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
|
||||
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
|
||||
if (
|
||||
defaultMode === "isolated_workspace" ||
|
||||
defaultMode === "operator_branch" ||
|
||||
defaultMode === "adapter_default"
|
||||
) {
|
||||
return defaultMode === "adapter_default" ? "agent_default" : defaultMode;
|
||||
}
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | undefined) {
|
||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
||||
return mode;
|
||||
}
|
||||
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
|
||||
return "agent_default";
|
||||
}
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
export function NewIssueDialog() {
|
||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { enabled: showExperimentalWorkspaceUi } = useExperimentalWorkspacesEnabled();
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -175,11 +217,13 @@ export function NewIssueDialog() {
|
||||
const [priority, setPriority] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
||||
const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState<string>("shared_workspace");
|
||||
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -209,6 +253,20 @@ export function NewIssueDialog() {
|
||||
queryFn: () => projectsApi.list(effectiveCompanyId!),
|
||||
enabled: !!effectiveCompanyId && newIssueOpen,
|
||||
});
|
||||
const { data: reusableExecutionWorkspaces } = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.list(effectiveCompanyId!, {
|
||||
projectId,
|
||||
projectWorkspaceId: projectWorkspaceId || undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
queryFn: () =>
|
||||
executionWorkspacesApi.list(effectiveCompanyId!, {
|
||||
projectId,
|
||||
projectWorkspaceId: projectWorkspaceId || undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
enabled: Boolean(effectiveCompanyId) && newIssueOpen && showExperimentalWorkspaceUi && Boolean(projectId),
|
||||
});
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
@@ -297,10 +355,12 @@ export function NewIssueDialog() {
|
||||
priority,
|
||||
assigneeId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
useIsolatedExecutionWorkspace,
|
||||
executionWorkspaceMode,
|
||||
selectedExecutionWorkspaceId,
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
@@ -309,10 +369,12 @@ export function NewIssueDialog() {
|
||||
priority,
|
||||
assigneeId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
useIsolatedExecutionWorkspace,
|
||||
executionWorkspaceMode,
|
||||
selectedExecutionWorkspaceId,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
]);
|
||||
@@ -329,34 +391,52 @@ export function NewIssueDialog() {
|
||||
setDescription(newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(newIssueDefaults.projectId ?? "");
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||
} else if (draft && draft.title.trim()) {
|
||||
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
||||
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
||||
setTitle(draft.title);
|
||||
setDescription(draft.description);
|
||||
setStatus(draft.status || "todo");
|
||||
setPriority(draft.priority);
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
|
||||
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
|
||||
setProjectId(restoredProjectId);
|
||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
|
||||
setExecutionWorkspaceMode(
|
||||
draft.executionWorkspaceMode
|
||||
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
|
||||
);
|
||||
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = restoredProjectId || null;
|
||||
} else {
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(newIssueDefaults.projectId ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsAssigneeOverrides) {
|
||||
@@ -392,11 +472,13 @@ export function NewIssueDialog() {
|
||||
setPriority("");
|
||||
setAssigneeId("");
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setExecutionWorkspaceMode("shared_workspace");
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setCompanyOpen(false);
|
||||
@@ -408,10 +490,12 @@ export function NewIssueDialog() {
|
||||
setDialogCompanyId(companyId);
|
||||
setAssigneeId("");
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setExecutionWorkspaceMode("shared_workspace");
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
@@ -429,13 +513,18 @@ export function NewIssueDialog() {
|
||||
chrome: assigneeChrome,
|
||||
});
|
||||
const selectedProject = orderedProjects.find((project) => project.id === projectId);
|
||||
const executionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
|
||||
const executionWorkspacePolicy = showExperimentalWorkspaceUi
|
||||
? selectedProject?.executionWorkspacePolicy
|
||||
: null;
|
||||
const selectedReusableExecutionWorkspace = (reusableExecutionWorkspaces ?? []).find(
|
||||
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
||||
);
|
||||
const requestedExecutionWorkspaceMode =
|
||||
executionWorkspaceMode === "reuse_existing"
|
||||
? issueExecutionWorkspaceModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
|
||||
: executionWorkspaceMode;
|
||||
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
|
||||
? {
|
||||
mode: useIsolatedExecutionWorkspace ? "isolated" : "project_primary",
|
||||
}
|
||||
? { mode: requestedExecutionWorkspaceMode }
|
||||
: null;
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
@@ -445,7 +534,12 @@ export function NewIssueDialog() {
|
||||
priority: priority || "medium",
|
||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||
...(executionWorkspacePolicy?.enabled ? { executionWorkspacePreference: executionWorkspaceMode } : {}),
|
||||
...(executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId
|
||||
? { executionWorkspaceId: selectedExecutionWorkspaceId }
|
||||
: {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
});
|
||||
}
|
||||
@@ -477,10 +571,14 @@ export function NewIssueDialog() {
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
||||
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
|
||||
const currentProjectWorkspaces = currentProject?.workspaces ?? [];
|
||||
const currentProjectExecutionWorkspacePolicy = showExperimentalWorkspaceUi
|
||||
? currentProject?.executionWorkspacePolicy ?? null
|
||||
: null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const selectedReusableExecutionWorkspace = (reusableExecutionWorkspaces ?? []).find(
|
||||
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
||||
);
|
||||
const assigneeOptionsTitle =
|
||||
assigneeAdapterType === "claude_local"
|
||||
? "Claude options"
|
||||
@@ -526,9 +624,10 @@ export function NewIssueDialog() {
|
||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||
setProjectId(nextProjectId);
|
||||
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
|
||||
const policy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI ? nextProject?.executionWorkspacePolicy : null;
|
||||
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
|
||||
setUseIsolatedExecutionWorkspace(Boolean(policy?.enabled && policy.defaultMode === "isolated"));
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject));
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
}, [orderedProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -538,14 +637,10 @@ export function NewIssueDialog() {
|
||||
const project = orderedProjects.find((entry) => entry.id === projectId);
|
||||
if (!project) return;
|
||||
executionWorkspaceDefaultProjectId.current = projectId;
|
||||
setUseIsolatedExecutionWorkspace(
|
||||
Boolean(
|
||||
SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI &&
|
||||
project.executionWorkspacePolicy?.enabled &&
|
||||
project.executionWorkspacePolicy.defaultMode === "isolated",
|
||||
),
|
||||
);
|
||||
}, [newIssueOpen, orderedProjects, projectId]);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(project));
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(project));
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
}, [newIssueOpen, orderedProjects, projectId, showExperimentalWorkspaceUi]);
|
||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||
() => {
|
||||
return [...(assigneeAdapterModels ?? [])]
|
||||
@@ -800,31 +895,72 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentProjectSupportsExecutionWorkspace && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-medium">Use isolated issue checkout</div>
|
||||
{showExperimentalWorkspaceUi && currentProject && (
|
||||
<div className="px-4 pb-2 shrink-0 space-y-2">
|
||||
{currentProjectWorkspaces.length > 0 && (
|
||||
<div className="rounded-md border border-border px-3 py-2 space-y-1.5">
|
||||
<div className="text-xs font-medium">Codebase</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Create an issue-specific execution workspace instead of using the project's primary checkout.
|
||||
Choose which project workspace this issue should use.
|
||||
</div>
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={projectWorkspaceId}
|
||||
onChange={(e) => setProjectWorkspaceId(e.target.value)}
|
||||
>
|
||||
{currentProjectWorkspaces.map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>
|
||||
{workspace.name}
|
||||
{workspace.isPrimary ? " (default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
useIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
|
||||
{currentProjectSupportsExecutionWorkspace && (
|
||||
<div className="rounded-md border border-border px-3 py-2 space-y-1.5">
|
||||
<div className="text-xs font-medium">Execution workspace</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Control whether this issue runs in the shared workspace, a new isolated workspace, or an existing one.
|
||||
</div>
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={executionWorkspaceMode}
|
||||
onChange={(e) => {
|
||||
setExecutionWorkspaceMode(e.target.value);
|
||||
if (e.target.value !== "reuse_existing") {
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{EXECUTION_WORKSPACE_MODES.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{executionWorkspaceMode === "reuse_existing" && (
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={selectedExecutionWorkspaceId}
|
||||
onChange={(e) => setSelectedExecutionWorkspaceId(e.target.value)}
|
||||
>
|
||||
<option value="">Choose an existing workspace</option>
|
||||
{(reusableExecutionWorkspaces ?? []).map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>
|
||||
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
onClick={() => setUseIsolatedExecutionWorkspace((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
useIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } fr
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { DraftInput } from "./agent-config-primitives";
|
||||
import { InlineEditor } from "./InlineEditor";
|
||||
import { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
|
||||
|
||||
const PROJECT_STATUSES = [
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
@@ -26,9 +27,6 @@ const PROJECT_STATUSES = [
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
];
|
||||
|
||||
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
|
||||
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
|
||||
|
||||
interface ProjectPropertiesProps {
|
||||
project: Project;
|
||||
onUpdate?: (data: Record<string, unknown>) => void;
|
||||
@@ -154,6 +152,7 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
|
||||
|
||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { enabled: showExperimentalWorkspaceUi } = useExperimentalWorkspacesEnabled();
|
||||
const queryClient = useQueryClient();
|
||||
const [goalOpen, setGoalOpen] = useState(false);
|
||||
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
|
||||
@@ -195,7 +194,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
|
||||
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
|
||||
const executionWorkspaceDefaultMode =
|
||||
executionWorkspacePolicy?.defaultMode === "isolated" ? "isolated" : "project_primary";
|
||||
executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace";
|
||||
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
|
||||
type: "git_worktree",
|
||||
baseRef: "",
|
||||
@@ -710,7 +709,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && (
|
||||
{showExperimentalWorkspaceUi && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
|
||||
@@ -785,21 +784,21 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
executionWorkspaceDefaultMode === "isolated" ? "bg-green-600" : "bg-muted",
|
||||
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
commitField(
|
||||
"execution_workspace_default_mode",
|
||||
updateExecutionWorkspacePolicy({
|
||||
defaultMode: executionWorkspaceDefaultMode === "isolated" ? "project_primary" : "isolated",
|
||||
defaultMode: executionWorkspaceDefaultMode === "isolated_workspace" ? "shared_workspace" : "isolated_workspace",
|
||||
})!,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
executionWorkspaceDefaultMode === "isolated" ? "translate-x-4.5" : "translate-x-0.5",
|
||||
executionWorkspaceDefaultMode === "isolated_workspace" ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
39
ui/src/lib/experimentalSettings.ts
Normal file
39
ui/src/lib/experimentalSettings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const WORKSPACES_KEY = "paperclip:experimental:workspaces";
|
||||
|
||||
export function loadExperimentalWorkspacesEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(WORKSPACES_KEY) === "true";
|
||||
}
|
||||
|
||||
export function saveExperimentalWorkspacesEnabled(enabled: boolean) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(WORKSPACES_KEY, enabled ? "true" : "false");
|
||||
window.dispatchEvent(new CustomEvent("paperclip:experimental:workspaces", { detail: enabled }));
|
||||
}
|
||||
|
||||
export function useExperimentalWorkspacesEnabled() {
|
||||
const [enabled, setEnabled] = useState(loadExperimentalWorkspacesEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== WORKSPACES_KEY) return;
|
||||
setEnabled(loadExperimentalWorkspacesEnabled());
|
||||
};
|
||||
const handleCustom = () => setEnabled(loadExperimentalWorkspacesEnabled());
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener("paperclip:experimental:workspaces", handleCustom as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener("paperclip:experimental:workspaces", handleCustom as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const update = (next: boolean) => {
|
||||
saveExperimentalWorkspacesEnabled(next);
|
||||
setEnabled(next);
|
||||
};
|
||||
|
||||
return { enabled, setEnabled: update };
|
||||
}
|
||||
@@ -110,6 +110,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: `Issue ${id}`,
|
||||
@@ -125,6 +126,8 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
|
||||
@@ -32,6 +32,12 @@ export const queryKeys = {
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const,
|
||||
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
|
||||
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
|
||||
},
|
||||
executionWorkspaces: {
|
||||
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||
["execution-workspaces", companyId, filters ?? {}] as const,
|
||||
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
|
||||
},
|
||||
projects: {
|
||||
list: (companyId: string) => ["projects", companyId] as const,
|
||||
|
||||
70
ui/src/pages/ExecutionWorkspaceDetail.tsx
Normal file
70
ui/src/pages/ExecutionWorkspaceDetail.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1.5">
|
||||
<div className="w-28 shrink-0 text-xs text-muted-foreground">{label}</div>
|
||||
<div className="min-w-0 flex-1 text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionWorkspaceDetail() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
|
||||
const { data: workspace, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
|
||||
queryFn: () => executionWorkspacesApi.get(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (error) return <p className="text-sm text-destructive">{error instanceof Error ? error.message : "Failed to load workspace"}</p>;
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Execution workspace</div>
|
||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{workspace.status} · {workspace.mode} · {workspace.providerType}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<DetailRow label="Project">
|
||||
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source issue">
|
||||
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
|
||||
<DetailRow label="Base ref">{workspace.baseRef ?? "None"}</DetailRow>
|
||||
<DetailRow label="Working dir">
|
||||
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Provider ref">
|
||||
<span className="break-all font-mono text-xs">{workspace.providerRef ?? "None"}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Repo URL">
|
||||
{workspace.repoUrl ? (
|
||||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{workspace.repoUrl}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Opened">{new Date(workspace.openedAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Last used">{new Date(workspace.lastUsedAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Cleanup">
|
||||
{workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"}
|
||||
</DetailRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatDateTime, relativeTime } from "../lib/utils";
|
||||
import { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
@@ -30,6 +31,7 @@ export function InstanceSettings() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const { enabled: workspacesEnabled, setEnabled: setWorkspacesEnabled } = useExperimentalWorkspacesEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
@@ -110,6 +112,34 @@ export function InstanceSettings() {
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="space-y-3 rounded-lg border border-border bg-card p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">Experimental</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
UI-only feature flags for in-progress product surfaces.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">Workspaces</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Show workspace, execution workspace, and work product controls in project and issue UI.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={workspacesEnabled ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setWorkspacesEnabled(!workspacesEnabled)}
|
||||
>
|
||||
{workspacesEnabled ? "Enabled" : "Disabled"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||
@@ -36,15 +37,21 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
Paperclip,
|
||||
Rocket,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import type { ActivityEvent, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import type { Agent, IssueAttachment } from "@paperclipai/shared";
|
||||
|
||||
type CommentReassignment = {
|
||||
@@ -133,6 +140,24 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
||||
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
function workProductIcon(product: IssueWorkProduct) {
|
||||
switch (product.type) {
|
||||
case "pull_request":
|
||||
return <GitPullRequest className="h-3.5 w-3.5" />;
|
||||
case "branch":
|
||||
case "commit":
|
||||
return <GitBranch className="h-3.5 w-3.5" />;
|
||||
case "artifact":
|
||||
return <Package className="h-3.5 w-3.5" />;
|
||||
case "document":
|
||||
return <FileText className="h-3.5 w-3.5" />;
|
||||
case "runtime_service":
|
||||
return <Rocket className="h-3.5 w-3.5" />;
|
||||
default:
|
||||
return <ExternalLink className="h-3.5 w-3.5" />;
|
||||
}
|
||||
}
|
||||
|
||||
function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<string, Agent> }) {
|
||||
const id = evt.actorId;
|
||||
if (evt.actorType === "agent") {
|
||||
@@ -147,6 +172,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
|
||||
export function IssueDetail() {
|
||||
const { issueId } = useParams<{ issueId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { enabled: experimentalWorkspacesEnabled } = useExperimentalWorkspacesEnabled();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -160,6 +186,13 @@ export function IssueDetail() {
|
||||
cost: false,
|
||||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [newWorkProductType, setNewWorkProductType] = useState<IssueWorkProduct["type"]>("preview_url");
|
||||
const [newWorkProductProvider, setNewWorkProductProvider] = useState("paperclip");
|
||||
const [newWorkProductTitle, setNewWorkProductTitle] = useState("");
|
||||
const [newWorkProductUrl, setNewWorkProductUrl] = useState("");
|
||||
const [newWorkProductStatus, setNewWorkProductStatus] = useState<IssueWorkProduct["status"]>("active");
|
||||
const [newWorkProductReviewState, setNewWorkProductReviewState] = useState<IssueWorkProduct["reviewState"]>("none");
|
||||
const [newWorkProductSummary, setNewWorkProductSummary] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -387,6 +420,7 @@ export function IssueDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(issueId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
@@ -471,6 +505,42 @@ export function IssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const createWorkProduct = useMutation({
|
||||
mutationFn: () =>
|
||||
issuesApi.createWorkProduct(issueId!, {
|
||||
type: newWorkProductType,
|
||||
provider: newWorkProductProvider,
|
||||
title: newWorkProductTitle.trim(),
|
||||
url: newWorkProductUrl.trim() || null,
|
||||
status: newWorkProductStatus,
|
||||
reviewState: newWorkProductReviewState,
|
||||
summary: newWorkProductSummary.trim() || null,
|
||||
projectId: issue?.projectId ?? null,
|
||||
executionWorkspaceId: issue?.currentExecutionWorkspace?.id ?? issue?.executionWorkspaceId ?? null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNewWorkProductTitle("");
|
||||
setNewWorkProductUrl("");
|
||||
setNewWorkProductSummary("");
|
||||
setNewWorkProductType("preview_url");
|
||||
setNewWorkProductProvider("paperclip");
|
||||
setNewWorkProductStatus("active");
|
||||
setNewWorkProductReviewState("none");
|
||||
invalidateIssue();
|
||||
},
|
||||
});
|
||||
|
||||
const updateWorkProduct = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.updateWorkProduct(id, data),
|
||||
onSuccess: () => invalidateIssue(),
|
||||
});
|
||||
|
||||
const deleteWorkProduct = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.deleteWorkProduct(id),
|
||||
onSuccess: () => invalidateIssue(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
||||
setBreadcrumbs([
|
||||
@@ -508,6 +578,11 @@ export function IssueDetail() {
|
||||
|
||||
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
|
||||
const ancestors = issue.ancestors ?? [];
|
||||
const workProducts = issue.workProducts ?? [];
|
||||
const showOutputsTab =
|
||||
experimentalWorkspacesEnabled ||
|
||||
Boolean(issue.currentExecutionWorkspace) ||
|
||||
workProducts.length > 0;
|
||||
|
||||
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = evt.target.files?.[0];
|
||||
@@ -759,6 +834,12 @@ export function IssueDetail() {
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Comments
|
||||
</TabsTrigger>
|
||||
{showOutputsTab && (
|
||||
<TabsTrigger value="outputs" className="gap-1.5">
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
Outputs
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="subissues" className="gap-1.5">
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
Sub-issues
|
||||
@@ -798,6 +879,199 @@ export function IssueDetail() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{showOutputsTab && (
|
||||
<TabsContent value="outputs" className="space-y-4">
|
||||
{issue.currentExecutionWorkspace && (
|
||||
<div className="rounded-lg border border-border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Execution workspace</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{issue.currentExecutionWorkspace.status} · {issue.currentExecutionWorkspace.mode}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Open
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{issue.currentExecutionWorkspace.branchName ?? issue.currentExecutionWorkspace.cwd ?? "No workspace path recorded."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border p-3 space-y-3">
|
||||
<div className="text-sm font-medium">Work product</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<select
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={newWorkProductType}
|
||||
onChange={(e) => setNewWorkProductType(e.target.value as IssueWorkProduct["type"])}
|
||||
>
|
||||
<option value="preview_url">Preview URL</option>
|
||||
<option value="runtime_service">Runtime service</option>
|
||||
<option value="pull_request">Pull request</option>
|
||||
<option value="branch">Branch</option>
|
||||
<option value="commit">Commit</option>
|
||||
<option value="artifact">Artifact</option>
|
||||
<option value="document">Document</option>
|
||||
</select>
|
||||
<input
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={newWorkProductProvider}
|
||||
onChange={(e) => setNewWorkProductProvider(e.target.value)}
|
||||
placeholder="Provider"
|
||||
/>
|
||||
<input
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2"
|
||||
value={newWorkProductTitle}
|
||||
onChange={(e) => setNewWorkProductTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
/>
|
||||
<input
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2"
|
||||
value={newWorkProductUrl}
|
||||
onChange={(e) => setNewWorkProductUrl(e.target.value)}
|
||||
placeholder="URL"
|
||||
/>
|
||||
<select
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={newWorkProductStatus}
|
||||
onChange={(e) => setNewWorkProductStatus(e.target.value as IssueWorkProduct["status"])}
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="ready_for_review">Ready for review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="changes_requested">Changes requested</option>
|
||||
<option value="merged">Merged</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<select
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={newWorkProductReviewState}
|
||||
onChange={(e) => setNewWorkProductReviewState(e.target.value as IssueWorkProduct["reviewState"])}
|
||||
>
|
||||
<option value="none">No review state</option>
|
||||
<option value="needs_board_review">Needs board review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="changes_requested">Changes requested</option>
|
||||
</select>
|
||||
<textarea
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2 min-h-20"
|
||||
value={newWorkProductSummary}
|
||||
onChange={(e) => setNewWorkProductSummary(e.target.value)}
|
||||
placeholder="Summary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!newWorkProductTitle.trim() || createWorkProduct.isPending}
|
||||
onClick={() => createWorkProduct.mutate()}
|
||||
>
|
||||
{createWorkProduct.isPending ? "Adding..." : "Add output"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workProducts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No work product yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workProducts.map((product) => (
|
||||
<div key={product.id} className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{workProductIcon(product)}
|
||||
<span className="truncate">{product.title}</span>
|
||||
{product.isPrimary && (
|
||||
<span className="rounded-full border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{product.type.replace(/_/g, " ")} · {product.provider}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Delete "${product.title}"?`)) return;
|
||||
deleteWorkProduct.mutate(product.id);
|
||||
}}
|
||||
disabled={deleteWorkProduct.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{product.url && (
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{product.url}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
{product.summary && (
|
||||
<div className="text-xs text-muted-foreground">{product.summary}</div>
|
||||
)}
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<select
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={product.status}
|
||||
onChange={(e) =>
|
||||
updateWorkProduct.mutate({ id: product.id, data: { status: e.target.value } })}
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="ready_for_review">Ready for review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="changes_requested">Changes requested</option>
|
||||
<option value="merged">Merged</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<select
|
||||
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={product.reviewState}
|
||||
onChange={(e) =>
|
||||
updateWorkProduct.mutate({ id: product.id, data: { reviewState: e.target.value } })}
|
||||
>
|
||||
<option value="none">No review state</option>
|
||||
<option value="needs_board_review">Needs board review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="changes_requested">Changes requested</option>
|
||||
</select>
|
||||
<Button
|
||||
variant={product.isPrimary ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateWorkProduct.mutate({ id: product.id, data: { isPrimary: true } })}
|
||||
disabled={product.isPrimary || updateWorkProduct.isPending}
|
||||
>
|
||||
{product.isPrimary ? "Primary" : "Make primary"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="subissues">
|
||||
{childIssues.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No sub-issues.</p>
|
||||
|
||||
Reference in New Issue
Block a user