mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[codex] Respect manual workspace runtime controls (#4125)
## Thinking Path > - Paperclip orchestrates AI agents inside execution and project workspaces > - Workspace runtime services can be controlled manually by operators and reused by agent runs > - Manual start/stop state was not preserved consistently across workspace policies and routine launches > - Routine launches also needed branch/workspace variables to default from the selected workspace context > - This pull request makes runtime policy state explicit, preserves manual control, and auto-fills routine branch variables from workspace data > - The benefit is less surprising workspace service behavior and fewer manual inputs when running workspace-scoped routines ## What Changed - Added runtime-state handling for manual workspace control across execution and project workspace validators, routes, and services. - Updated heartbeat/runtime startup behavior so manually stopped services are respected. - Auto-filled routine workspace branch variables from available workspace context. - Added focused server and UI tests for workspace runtime and routine variable behavior. - Removed muted gray background styling from workspace pages and cards for a cleaner workspace UI. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm exec vitest run server/src/__tests__/routines-service.test.ts server/src/__tests__/workspace-runtime.test.ts ui/src/components/RoutineRunVariablesDialog.test.tsx` - Result: 55 tests passed, 21 skipped. The embedded Postgres routines tests skipped on this host with the existing PGlite/Postgres init warning; workspace-runtime and UI tests passed. ## Risks - Medium risk: this touches runtime service start/stop policy and heartbeat launch behavior. - The focused tests cover manual runtime state, routine variables, and workspace runtime reuse paths. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, tool-enabled local shell and GitHub workflow, exact runtime context window not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why targeted component/service verification is sufficient here - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -68,6 +68,8 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
|
|||||||
|
|
||||||
export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5;
|
export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5;
|
||||||
|
|
||||||
|
export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch";
|
||||||
|
|
||||||
export const AGENT_ICON_NAMES = [
|
export const AGENT_ICON_NAMES = [
|
||||||
"bot",
|
"bot",
|
||||||
"cpu",
|
"cpu",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export {
|
|||||||
AGENT_ROLES,
|
AGENT_ROLES,
|
||||||
AGENT_ROLE_LABELS,
|
AGENT_ROLE_LABELS,
|
||||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||||
|
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||||
AGENT_ICON_NAMES,
|
AGENT_ICON_NAMES,
|
||||||
ISSUE_STATUSES,
|
ISSUE_STATUSES,
|
||||||
INBOX_MINE_ISSUE_STATUSES,
|
INBOX_MINE_ISSUE_STATUSES,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export type ExecutionWorkspaceCloseActionKind =
|
|||||||
| "git_branch_delete"
|
| "git_branch_delete"
|
||||||
| "remove_local_directory";
|
| "remove_local_directory";
|
||||||
|
|
||||||
export type WorkspaceRuntimeDesiredState = "running" | "stopped";
|
export type WorkspaceRuntimeDesiredState = "running" | "stopped" | "manual";
|
||||||
export type WorkspaceRuntimeServiceStateMap = Record<string, WorkspaceRuntimeDesiredState>;
|
export type WorkspaceRuntimeServiceStateMap = Record<string, WorkspaceRuntimeDesiredState>;
|
||||||
export type WorkspaceCommandKind = "service" | "job";
|
export type WorkspaceCommandKind = "service" | "job";
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export const executionWorkspaceConfigSchema = z.object({
|
|||||||
teardownCommand: z.string().optional().nullable(),
|
teardownCommand: z.string().optional().nullable(),
|
||||||
cleanupCommand: z.string().optional().nullable(),
|
cleanupCommand: z.string().optional().nullable(),
|
||||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||||
desiredState: z.enum(["running", "stopped"]).optional().nullable(),
|
desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(),
|
||||||
serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(),
|
serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const workspaceRuntimeControlTargetSchema = z.object({
|
export const workspaceRuntimeControlTargetSchema = z.object({
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export const projectExecutionWorkspacePolicySchema = z
|
|||||||
|
|
||||||
export const projectWorkspaceRuntimeConfigSchema = z.object({
|
export const projectWorkspaceRuntimeConfigSchema = z.object({
|
||||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||||
desiredState: z.enum(["running", "stopped"]).optional().nullable(),
|
desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(),
|
||||||
serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(),
|
serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]);
|
const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]);
|
||||||
|
|||||||
@@ -461,6 +461,90 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-populates workspaceBranch from a reused isolated workspace", async () => {
|
||||||
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||||
|
const projectWorkspaceId = randomUUID();
|
||||||
|
const executionWorkspaceId = randomUUID();
|
||||||
|
|
||||||
|
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||||
|
await db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, projectId));
|
||||||
|
await db.insert(projectWorkspaces).values({
|
||||||
|
id: projectWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
name: "Primary workspace",
|
||||||
|
isPrimary: true,
|
||||||
|
sharedWorkspaceKey: "routine-primary",
|
||||||
|
});
|
||||||
|
await db.insert(executionWorkspaces).values({
|
||||||
|
id: executionWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
mode: "isolated_workspace",
|
||||||
|
strategyType: "git_worktree",
|
||||||
|
name: "Routine worktree",
|
||||||
|
status: "active",
|
||||||
|
providerType: "git_worktree",
|
||||||
|
branchName: "pap-1634-routine-branch",
|
||||||
|
});
|
||||||
|
|
||||||
|
const branchRoutine = await svc.create(
|
||||||
|
companyId,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "Review {{workspaceBranch}}",
|
||||||
|
description: "Use branch {{workspaceBranch}}",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "active",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
variables: [
|
||||||
|
{ name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(branchRoutine.id, {
|
||||||
|
source: "manual",
|
||||||
|
executionWorkspaceId,
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedIssue = await db
|
||||||
|
.select({ title: issues.title, description: issues.description })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, run.linkedIssueId!))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
const storedRun = await db
|
||||||
|
.select({ triggerPayload: routineRuns.triggerPayload })
|
||||||
|
.from(routineRuns)
|
||||||
|
.where(eq(routineRuns.id, run.id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
expect(storedIssue?.title).toBe("Review pap-1634-routine-branch");
|
||||||
|
expect(storedIssue?.description).toBe("Use branch pap-1634-routine-branch");
|
||||||
|
expect(storedRun?.triggerPayload).toEqual({
|
||||||
|
variables: {
|
||||||
|
workspaceBranch: "pap-1634-routine-branch",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("runs draft routines with one-off agent and project overrides", async () => {
|
it("runs draft routines with one-off agent and project overrides", async () => {
|
||||||
const { companyId, agentId, projectId, svc } = await seedFixture();
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||||
const draftRoutine = await svc.create(
|
const draftRoutine = await svc.create(
|
||||||
|
|||||||
@@ -2035,6 +2035,37 @@ describe("realizeExecutionWorkspace", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ensureRuntimeServicesForRun", () => {
|
describe("ensureRuntimeServicesForRun", () => {
|
||||||
|
it("leaves manual runtime services untouched during agent runs", async () => {
|
||||||
|
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-manual-"));
|
||||||
|
const workspace = buildWorkspace(workspaceRoot);
|
||||||
|
|
||||||
|
const services = await ensureRuntimeServicesForRun({
|
||||||
|
runId: "run-manual",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
issue: null,
|
||||||
|
workspace,
|
||||||
|
config: {
|
||||||
|
desiredState: "manual",
|
||||||
|
workspaceRuntime: {
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "web",
|
||||||
|
command: "node -e \"throw new Error('should not start')\"",
|
||||||
|
port: { type: "auto" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adapterEnv: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(services).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("reuses shared runtime services across runs and starts a new service after release", async () => {
|
it("reuses shared runtime services across runs and starts a new service after release", async () => {
|
||||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
|
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
|
||||||
const workspace = buildWorkspace(workspaceRoot);
|
const workspace = buildWorkspace(workspaceRoot);
|
||||||
@@ -2604,6 +2635,41 @@ describe("buildWorkspaceRuntimeDesiredStatePatch", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves manual service state when manually starting or stopping services", () => {
|
||||||
|
const baseInput = {
|
||||||
|
config: {
|
||||||
|
workspaceRuntime: {
|
||||||
|
services: [
|
||||||
|
{ name: "web", command: "pnpm dev" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
currentDesiredState: "manual" as const,
|
||||||
|
currentServiceStates: null,
|
||||||
|
serviceIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildWorkspaceRuntimeDesiredStatePatch({
|
||||||
|
...baseInput,
|
||||||
|
action: "start",
|
||||||
|
})).toEqual({
|
||||||
|
desiredState: "manual",
|
||||||
|
serviceStates: {
|
||||||
|
"0": "manual",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildWorkspaceRuntimeDesiredStatePatch({
|
||||||
|
...baseInput,
|
||||||
|
action: "stop",
|
||||||
|
})).toEqual({
|
||||||
|
desiredState: "manual",
|
||||||
|
serviceStates: {
|
||||||
|
"0": "manual",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => {
|
describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
updateExecutionWorkspaceSchema,
|
updateExecutionWorkspaceSchema,
|
||||||
workspaceRuntimeControlTargetSchema,
|
workspaceRuntimeControlTargetSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
|
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||||
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
|
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
|
||||||
@@ -357,14 +358,14 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||||||
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0;
|
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentDesiredState: "running" | "stopped" =
|
const currentDesiredState: WorkspaceRuntimeDesiredState =
|
||||||
existing.config?.desiredState
|
existing.config?.desiredState
|
||||||
?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running")
|
?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running")
|
||||||
? "running"
|
? "running"
|
||||||
: "stopped");
|
: "stopped");
|
||||||
const nextRuntimeState: {
|
const nextRuntimeState: {
|
||||||
desiredState: "running" | "stopped";
|
desiredState: WorkspaceRuntimeDesiredState;
|
||||||
serviceStates: Record<string, "running" | "stopped"> | null | undefined;
|
serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
|
||||||
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
|
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
|
||||||
? {
|
? {
|
||||||
desiredState: currentDesiredState,
|
desiredState: currentDesiredState,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
updateProjectWorkspaceSchema,
|
updateProjectWorkspaceSchema,
|
||||||
workspaceRuntimeControlTargetSchema,
|
workspaceRuntimeControlTargetSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
|
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
|
||||||
@@ -488,14 +489,14 @@ export function projectRoutes(db: Db) {
|
|||||||
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0;
|
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentDesiredState: "running" | "stopped" =
|
const currentDesiredState: WorkspaceRuntimeDesiredState =
|
||||||
workspace.runtimeConfig?.desiredState
|
workspace.runtimeConfig?.desiredState
|
||||||
?? ((workspace.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running")
|
?? ((workspace.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running")
|
||||||
? "running"
|
? "running"
|
||||||
: "stopped");
|
: "stopped");
|
||||||
const nextRuntimeState: {
|
const nextRuntimeState: {
|
||||||
desiredState: "running" | "stopped";
|
desiredState: WorkspaceRuntimeDesiredState;
|
||||||
serviceStates: Record<string, "running" | "stopped"> | null | undefined;
|
serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
|
||||||
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
|
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
|
||||||
? {
|
? {
|
||||||
desiredState: currentDesiredState,
|
desiredState: currentDesiredState,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
ExecutionWorkspaceCloseGitReadiness,
|
ExecutionWorkspaceCloseGitReadiness,
|
||||||
ExecutionWorkspaceCloseReadiness,
|
ExecutionWorkspaceCloseReadiness,
|
||||||
ExecutionWorkspaceConfig,
|
ExecutionWorkspaceConfig,
|
||||||
|
WorkspaceRuntimeDesiredState,
|
||||||
WorkspaceRuntimeService,
|
WorkspaceRuntimeService,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||||
@@ -40,6 +41,20 @@ function cloneRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return { ...value };
|
return { ...value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readDesiredState(value: unknown): WorkspaceRuntimeDesiredState | null {
|
||||||
|
return value === "running" || value === "stopped" || value === "manual" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readServiceStates(value: unknown): ExecutionWorkspaceConfig["serviceStates"] {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const entries = Object.entries(value).filter(([, state]) =>
|
||||||
|
state === "running" || state === "stopped" || state === "manual"
|
||||||
|
);
|
||||||
|
return entries.length > 0
|
||||||
|
? Object.fromEntries(entries) as ExecutionWorkspaceConfig["serviceStates"]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
async function pathExists(value: string | null | undefined) {
|
async function pathExists(value: string | null | undefined) {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
try {
|
try {
|
||||||
@@ -192,12 +207,8 @@ export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> |
|
|||||||
teardownCommand: readNullableString(raw.teardownCommand),
|
teardownCommand: readNullableString(raw.teardownCommand),
|
||||||
cleanupCommand: readNullableString(raw.cleanupCommand),
|
cleanupCommand: readNullableString(raw.cleanupCommand),
|
||||||
workspaceRuntime: cloneRecord(raw.workspaceRuntime),
|
workspaceRuntime: cloneRecord(raw.workspaceRuntime),
|
||||||
desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null,
|
desiredState: readDesiredState(raw.desiredState),
|
||||||
serviceStates: isRecord(raw.serviceStates)
|
serviceStates: readServiceStates(raw.serviceStates),
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(raw.serviceStates).filter(([, state]) => state === "running" || state === "stopped"),
|
|
||||||
) as ExecutionWorkspaceConfig["serviceStates"]
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasConfig = Object.values(config).some((value) => {
|
const hasConfig = Object.values(config).some((value) => {
|
||||||
@@ -235,18 +246,10 @@ export function mergeExecutionWorkspaceConfig(
|
|||||||
workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime,
|
workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime,
|
||||||
desiredState:
|
desiredState:
|
||||||
patch.desiredState !== undefined
|
patch.desiredState !== undefined
|
||||||
? patch.desiredState === "running" || patch.desiredState === "stopped"
|
? readDesiredState(patch.desiredState)
|
||||||
? patch.desiredState
|
|
||||||
: null
|
|
||||||
: current.desiredState,
|
: current.desiredState,
|
||||||
serviceStates:
|
serviceStates:
|
||||||
patch.serviceStates !== undefined && isRecord(patch.serviceStates)
|
patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates,
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(patch.serviceStates).filter(([, state]) => state === "running" || state === "stopped"),
|
|
||||||
) as ExecutionWorkspaceConfig["serviceStates"]
|
|
||||||
: patch.serviceStates !== undefined
|
|
||||||
? null
|
|
||||||
: current.serviceStates,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasConfig = Object.values(nextConfig).some((value) => {
|
const hasConfig = Object.values(nextConfig).some((value) => {
|
||||||
|
|||||||
@@ -270,6 +270,16 @@ export function applyPersistedExecutionWorkspaceConfig(input: {
|
|||||||
} else if (input.workspaceConfig?.workspaceRuntime) {
|
} else if (input.workspaceConfig?.workspaceRuntime) {
|
||||||
nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime };
|
nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime };
|
||||||
}
|
}
|
||||||
|
if (input.workspaceConfig?.desiredState === null) {
|
||||||
|
delete nextConfig.desiredState;
|
||||||
|
} else if (input.workspaceConfig?.desiredState) {
|
||||||
|
nextConfig.desiredState = input.workspaceConfig.desiredState;
|
||||||
|
}
|
||||||
|
if (input.workspaceConfig?.serviceStates === null) {
|
||||||
|
delete nextConfig.serviceStates;
|
||||||
|
} else if (input.workspaceConfig?.serviceStates) {
|
||||||
|
nextConfig.serviceStates = { ...input.workspaceConfig.serviceStates };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.workspaceConfig && input.mode === "isolated_workspace") {
|
if (input.workspaceConfig && input.mode === "isolated_workspace") {
|
||||||
@@ -329,6 +339,22 @@ function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>):
|
|||||||
const workspaceRuntime = parseObject(config.workspaceRuntime);
|
const workspaceRuntime = parseObject(config.workspaceRuntime);
|
||||||
snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null;
|
snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null;
|
||||||
}
|
}
|
||||||
|
if ("desiredState" in config) {
|
||||||
|
snapshot.desiredState =
|
||||||
|
config.desiredState === "running" || config.desiredState === "stopped" || config.desiredState === "manual"
|
||||||
|
? config.desiredState
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if ("serviceStates" in config) {
|
||||||
|
const serviceStates = parseObject(config.serviceStates);
|
||||||
|
snapshot.serviceStates = Object.keys(serviceStates).length > 0
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(serviceStates).filter(([, state]) =>
|
||||||
|
state === "running" || state === "stopped" || state === "manual"
|
||||||
|
),
|
||||||
|
) as ExecutionWorkspaceConfig["serviceStates"]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
const hasSnapshot = Object.values(snapshot).some((value) => {
|
const hasSnapshot = Object.values(snapshot).some((value) => {
|
||||||
if (value === null) return false;
|
if (value === null) return false;
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ function cloneRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] {
|
function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] {
|
||||||
return value === "running" || value === "stopped" ? value : null;
|
return value === "running" || value === "stopped" || value === "manual" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] {
|
function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] {
|
||||||
if (!isRecord(value)) return null;
|
if (!isRecord(value)) return null;
|
||||||
const entries = Object.entries(value).filter(([, state]) => state === "running" || state === "stopped");
|
const entries = Object.entries(value).filter(([, state]) =>
|
||||||
|
state === "running" || state === "stopped" || state === "manual"
|
||||||
|
);
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"];
|
return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db";
|
|||||||
import {
|
import {
|
||||||
agents,
|
agents,
|
||||||
companySecrets,
|
companySecrets,
|
||||||
|
executionWorkspaces,
|
||||||
goals,
|
goals,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issues,
|
issues,
|
||||||
@@ -27,7 +28,9 @@ import type {
|
|||||||
UpdateRoutineTrigger,
|
UpdateRoutineTrigger,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
|
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||||
getBuiltinRoutineVariableValues,
|
getBuiltinRoutineVariableValues,
|
||||||
|
extractRoutineVariableNames,
|
||||||
interpolateRoutineTemplate,
|
interpolateRoutineTemplate,
|
||||||
stringifyRoutineVariableValue,
|
stringifyRoutineVariableValue,
|
||||||
syncRoutineVariablesWithTemplate,
|
syncRoutineVariablesWithTemplate,
|
||||||
@@ -269,15 +272,23 @@ function resolveRoutineVariableValues(
|
|||||||
source: "schedule" | "manual" | "api" | "webhook";
|
source: "schedule" | "manual" | "api" | "webhook";
|
||||||
payload?: Record<string, unknown> | null;
|
payload?: Record<string, unknown> | null;
|
||||||
variables?: Record<string, unknown> | null;
|
variables?: Record<string, unknown> | null;
|
||||||
|
automaticVariables?: Record<string, string | number | boolean>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (variables.length === 0) return {} as Record<string, string | number | boolean>;
|
if (variables.length === 0) return {} as Record<string, string | number | boolean>;
|
||||||
const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables);
|
const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables);
|
||||||
|
const automaticVariables = input.automaticVariables ?? {};
|
||||||
const resolved: Record<string, string | number | boolean> = {};
|
const resolved: Record<string, string | number | boolean> = {};
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
for (const variable of variables) {
|
for (const variable of variables) {
|
||||||
const candidate = provided[variable.name] !== undefined ? provided[variable.name] : variable.defaultValue;
|
// Workspace-derived automatic values are authoritative for variables that
|
||||||
|
// Paperclip manages from execution context, so callers cannot override them.
|
||||||
|
const candidate = automaticVariables[variable.name] !== undefined
|
||||||
|
? automaticVariables[variable.name]
|
||||||
|
: provided[variable.name] !== undefined
|
||||||
|
? provided[variable.name]
|
||||||
|
: variable.defaultValue;
|
||||||
const normalized = normalizeRoutineVariableValue(variable, candidate);
|
const normalized = normalizeRoutineVariableValue(variable, candidate);
|
||||||
if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) {
|
if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) {
|
||||||
if (variable.required) missing.push(variable.name);
|
if (variable.required) missing.push(variable.name);
|
||||||
@@ -309,6 +320,11 @@ function mergeRoutineRunPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routineUsesWorkspaceBranch(routine: typeof routines.$inferSelect) {
|
||||||
|
return (routine.variables ?? []).some((variable) => variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE)
|
||||||
|
|| extractRoutineVariableNames([routine.title, routine.description]).includes(WORKSPACE_BRANCH_ROUTINE_VARIABLE);
|
||||||
|
}
|
||||||
|
|
||||||
export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
|
export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
|
||||||
const issueSvc = issueService(db);
|
const issueSvc = issueService(db);
|
||||||
const secretsSvc = secretService(db);
|
const secretsSvc = secretService(db);
|
||||||
@@ -701,11 +717,34 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||||||
if (!assigneeAgentId) {
|
if (!assigneeAgentId) {
|
||||||
throw unprocessable("Default agent required");
|
throw unprocessable("Default agent required");
|
||||||
}
|
}
|
||||||
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
|
const automaticVariables: Record<string, string | number | boolean> = {};
|
||||||
const allVariables = { ...getBuiltinRoutineVariableValues(), ...resolvedVariables };
|
if (input.executionWorkspaceId && routineUsesWorkspaceBranch(input.routine)) {
|
||||||
|
const workspace = await db
|
||||||
|
.select({
|
||||||
|
branchName: executionWorkspaces.branchName,
|
||||||
|
mode: executionWorkspaces.mode,
|
||||||
|
})
|
||||||
|
.from(executionWorkspaces)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(executionWorkspaces.id, input.executionWorkspaceId),
|
||||||
|
eq(executionWorkspaces.companyId, input.routine.companyId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
const branchName = workspace?.branchName?.trim();
|
||||||
|
if (workspace && workspace.mode !== "shared_workspace" && branchName) {
|
||||||
|
automaticVariables[WORKSPACE_BRANCH_ROUTINE_VARIABLE] = branchName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], {
|
||||||
|
...input,
|
||||||
|
automaticVariables,
|
||||||
|
});
|
||||||
|
const allVariables = { ...getBuiltinRoutineVariableValues(), ...automaticVariables, ...resolvedVariables };
|
||||||
const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
|
const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
|
||||||
const description = interpolateRoutineTemplate(input.routine.description, allVariables);
|
const description = interpolateRoutineTemplate(input.routine.description, allVariables);
|
||||||
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
|
const triggerPayload = mergeRoutineRunPayload(input.payload, { ...automaticVariables, ...resolvedVariables });
|
||||||
const run = await db.transaction(async (tx) => {
|
const run = await db.transaction(async (tx) => {
|
||||||
const txDb = tx as unknown as Db;
|
const txDb = tx as unknown as Db;
|
||||||
await tx.execute(
|
await tx.execute(
|
||||||
|
|||||||
@@ -2240,13 +2240,17 @@ function readConfiguredServiceStates(config: Record<string, unknown>) {
|
|||||||
const raw = parseObject(config.serviceStates);
|
const raw = parseObject(config.serviceStates);
|
||||||
const states: WorkspaceRuntimeServiceStateMap = {};
|
const states: WorkspaceRuntimeServiceStateMap = {};
|
||||||
for (const [key, value] of Object.entries(raw)) {
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
if (value === "running" || value === "stopped") {
|
if (value === "running" || value === "stopped" || value === "manual") {
|
||||||
states[key] = value;
|
states[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return states;
|
return states;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readDesiredRuntimeState(value: unknown): WorkspaceRuntimeDesiredState | null {
|
||||||
|
return value === "running" || value === "stopped" || value === "manual" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildWorkspaceRuntimeDesiredStatePatch(input: {
|
export function buildWorkspaceRuntimeDesiredStatePatch(input: {
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
currentDesiredState: WorkspaceRuntimeDesiredState | null;
|
currentDesiredState: WorkspaceRuntimeDesiredState | null;
|
||||||
@@ -2258,7 +2262,7 @@ export function buildWorkspaceRuntimeDesiredStatePatch(input: {
|
|||||||
serviceStates: WorkspaceRuntimeServiceStateMap | null;
|
serviceStates: WorkspaceRuntimeServiceStateMap | null;
|
||||||
} {
|
} {
|
||||||
const configuredServices = listConfiguredRuntimeServiceEntries(input.config);
|
const configuredServices = listConfiguredRuntimeServiceEntries(input.config);
|
||||||
const fallbackState: WorkspaceRuntimeDesiredState = input.currentDesiredState === "running" ? "running" : "stopped";
|
const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.currentDesiredState) ?? "stopped";
|
||||||
const nextServiceStates: WorkspaceRuntimeServiceStateMap = {};
|
const nextServiceStates: WorkspaceRuntimeServiceStateMap = {};
|
||||||
|
|
||||||
for (let index = 0; index < configuredServices.length; index += 1) {
|
for (let index = 0; index < configuredServices.length; index += 1) {
|
||||||
@@ -2266,15 +2270,26 @@ export function buildWorkspaceRuntimeDesiredStatePatch(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running";
|
const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running";
|
||||||
|
const applyActionState = (index: number) => {
|
||||||
|
const key = String(index);
|
||||||
|
// Manual services are intentionally left under operator control even when
|
||||||
|
// an API action targets that individual service.
|
||||||
|
if (nextServiceStates[key] === "manual") return;
|
||||||
|
nextServiceStates[key] = nextState;
|
||||||
|
};
|
||||||
if (input.serviceIndex === undefined || input.serviceIndex === null) {
|
if (input.serviceIndex === undefined || input.serviceIndex === null) {
|
||||||
for (let index = 0; index < configuredServices.length; index += 1) {
|
for (let index = 0; index < configuredServices.length; index += 1) {
|
||||||
nextServiceStates[String(index)] = nextState;
|
applyActionState(index);
|
||||||
}
|
}
|
||||||
} else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) {
|
} else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) {
|
||||||
nextServiceStates[String(input.serviceIndex)] = nextState;
|
applyActionState(input.serviceIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
const desiredState = Object.values(nextServiceStates).some((state) => state === "running") ? "running" : "stopped";
|
const desiredState = Object.values(nextServiceStates).some((state) => state === "running")
|
||||||
|
? "running"
|
||||||
|
: Object.values(nextServiceStates).some((state) => state === "manual")
|
||||||
|
? "manual"
|
||||||
|
: "stopped";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
desiredState,
|
desiredState,
|
||||||
@@ -2291,7 +2306,7 @@ function selectRuntimeServiceEntries(input: {
|
|||||||
}) {
|
}) {
|
||||||
const entries = listConfiguredRuntimeServiceEntries(input.config);
|
const entries = listConfiguredRuntimeServiceEntries(input.config);
|
||||||
const states = input.serviceStates ?? readConfiguredServiceStates(input.config);
|
const states = input.serviceStates ?? readConfiguredServiceStates(input.config);
|
||||||
const fallbackState: WorkspaceRuntimeDesiredState = input.defaultDesiredState === "running" ? "running" : "stopped";
|
const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.defaultDesiredState) ?? "stopped";
|
||||||
|
|
||||||
return entries.filter((_, index) => {
|
return entries.filter((_, index) => {
|
||||||
if (input.serviceIndex !== undefined && input.serviceIndex !== null) {
|
if (input.serviceIndex !== undefined && input.serviceIndex !== null) {
|
||||||
@@ -2313,7 +2328,12 @@ export async function ensureRuntimeServicesForRun(input: {
|
|||||||
adapterEnv: Record<string, string>;
|
adapterEnv: Record<string, string>;
|
||||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
}): Promise<RuntimeServiceRef[]> {
|
}): Promise<RuntimeServiceRef[]> {
|
||||||
const rawServices = readRuntimeServiceEntries(input.config);
|
const rawServices = selectRuntimeServiceEntries({
|
||||||
|
config: input.config,
|
||||||
|
respectDesiredStates: true,
|
||||||
|
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "running",
|
||||||
|
serviceStates: readConfiguredServiceStates(input.config),
|
||||||
|
});
|
||||||
const acquiredServiceIds: string[] = [];
|
const acquiredServiceIds: string[] = [];
|
||||||
const refs: RuntimeServiceRef[] = [];
|
const refs: RuntimeServiceRef[] = [];
|
||||||
runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds);
|
runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds);
|
||||||
@@ -2401,7 +2421,7 @@ export async function startRuntimeServicesForWorkspaceControl(input: {
|
|||||||
config: input.config,
|
config: input.config,
|
||||||
serviceIndex: input.serviceIndex,
|
serviceIndex: input.serviceIndex,
|
||||||
respectDesiredStates: input.respectDesiredStates,
|
respectDesiredStates: input.respectDesiredStates,
|
||||||
defaultDesiredState: input.config.desiredState === "running" ? "running" : "stopped",
|
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped",
|
||||||
serviceStates: readConfiguredServiceStates(input.config),
|
serviceStates: readConfiguredServiceStates(input.config),
|
||||||
});
|
});
|
||||||
const refs: RuntimeServiceRef[] = [];
|
const refs: RuntimeServiceRef[] = [];
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{readinessQuery.isLoading ? (
|
{readinessQuery.isLoading ? (
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 rounded-xl border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Checking whether this workspace is safe to close...
|
Checking whether this workspace is safe to close...
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +174,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||||||
{readiness.git ? (
|
{readiness.git ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Git status</h3>
|
<h3 className="text-sm font-medium">Git status</h3>
|
||||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
<div className="rounded-xl border border-border bg-background px-4 py-3 text-sm">
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
||||||
@@ -212,7 +212,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||||||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
<h3 className="text-sm font-medium">Other linked issues</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{otherLinkedIssues.map((issue) => (
|
{otherLinkedIssues.map((issue) => (
|
||||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
<div key={issue.id} className="rounded-xl border border-border bg-background px-4 py-3 text-sm">
|
||||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||||
{issue.identifier ?? issue.id} · {issue.title}
|
{issue.identifier ?? issue.id} · {issue.title}
|
||||||
@@ -230,7 +230,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||||||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{readiness.runtimeServices.map((service) => (
|
{readiness.runtimeServices.map((service) => (
|
||||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
<div key={service.id} className="rounded-xl border border-border bg-background px-4 py-3 text-sm">
|
||||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-medium">{service.serviceName}</span>
|
<span className="font-medium">{service.serviceName}</span>
|
||||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||||
@@ -248,7 +248,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||||||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{readiness.plannedActions.map((action, index) => (
|
{readiness.plannedActions.map((action, index) => (
|
||||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-background px-4 py-3 text-sm">
|
||||||
<div className="font-medium">{action.label}</div>
|
<div className="font-medium">{action.label}</div>
|
||||||
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||||
{action.command ? (
|
{action.command ? (
|
||||||
@@ -269,7 +269,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentStatus === "archived" ? (
|
{currentStatus === "archived" ? (
|
||||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
<div className="rounded-xl border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||||
This workspace is already archived.
|
This workspace is already archived.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ interface IssueWorkspaceCardProps {
|
|||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
initialEditing?: boolean;
|
initialEditing?: boolean;
|
||||||
livePreview?: boolean;
|
livePreview?: boolean;
|
||||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
|
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean; workspaceBranchName?: string | null }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssueWorkspaceCard({
|
export function IssueWorkspaceCard({
|
||||||
@@ -298,6 +298,10 @@ export function IssueWorkspaceCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
|
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
|
||||||
|
const draftWorkspaceBranchName =
|
||||||
|
draftSelection === "reuse_existing" && configuredReusableWorkspace?.mode !== "shared_workspace"
|
||||||
|
? configuredReusableWorkspace?.branchName ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
const buildWorkspaceDraftUpdate = useCallback(() => ({
|
const buildWorkspaceDraftUpdate = useCallback(() => ({
|
||||||
executionWorkspacePreference: draftSelection,
|
executionWorkspacePreference: draftSelection,
|
||||||
@@ -316,8 +320,11 @@ export function IssueWorkspaceCard({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onDraftChange) return;
|
if (!onDraftChange) return;
|
||||||
onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig });
|
onDraftChange(buildWorkspaceDraftUpdate(), {
|
||||||
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, onDraftChange]);
|
canSave: canSaveWorkspaceConfig,
|
||||||
|
workspaceBranchName: draftWorkspaceBranchName,
|
||||||
|
});
|
||||||
|
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
if (!canSaveWorkspaceConfig) return;
|
if (!canSaveWorkspaceConfig) return;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function ProjectWorkspaceSummaryCard({
|
|||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="inline-flex items-center rounded-full border border-border bg-muted/25 px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
<span className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
{workspaceKindLabel(summary.kind)}
|
{workspaceKindLabel(summary.kind)}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
<span className="inline-flex items-center rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
||||||
|
|
||||||
let issueWorkspaceDraftCalls = 0;
|
let issueWorkspaceDraftCalls = 0;
|
||||||
|
let issueWorkspaceDraft = {
|
||||||
|
executionWorkspaceId: null as string | null,
|
||||||
|
executionWorkspacePreference: "shared_workspace",
|
||||||
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||||
|
};
|
||||||
|
let issueWorkspaceBranchName: string | null = null;
|
||||||
|
|
||||||
vi.mock("../api/instanceSettings", () => ({
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
instanceSettingsApi: {
|
instanceSettingsApi: {
|
||||||
@@ -22,18 +28,20 @@ vi.mock("./IssueWorkspaceCard", async () => {
|
|||||||
IssueWorkspaceCard: ({
|
IssueWorkspaceCard: ({
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
}: {
|
}: {
|
||||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
|
onDraftChange?: (
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
meta: { canSave: boolean; workspaceBranchName?: string | null },
|
||||||
|
) => void;
|
||||||
}) => {
|
}) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
issueWorkspaceDraftCalls += 1;
|
issueWorkspaceDraftCalls += 1;
|
||||||
if (issueWorkspaceDraftCalls > 20) {
|
if (issueWorkspaceDraftCalls > 20) {
|
||||||
throw new Error("IssueWorkspaceCard onDraftChange looped");
|
throw new Error("IssueWorkspaceCard onDraftChange looped");
|
||||||
}
|
}
|
||||||
onDraftChange?.({
|
onDraftChange?.(issueWorkspaceDraft, {
|
||||||
executionWorkspaceId: null,
|
canSave: true,
|
||||||
executionWorkspacePreference: "shared_workspace",
|
workspaceBranchName: issueWorkspaceBranchName,
|
||||||
executionWorkspaceSettings: { mode: "shared_workspace" },
|
});
|
||||||
}, { canSave: true });
|
|
||||||
}, [onDraftChange]);
|
}, [onDraftChange]);
|
||||||
|
|
||||||
return <div data-testid="workspace-card">Workspace card</div>;
|
return <div data-testid="workspace-card">Workspace card</div>;
|
||||||
@@ -119,6 +127,12 @@ describe("RoutineRunVariablesDialog", () => {
|
|||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
issueWorkspaceDraftCalls = 0;
|
issueWorkspaceDraftCalls = 0;
|
||||||
|
issueWorkspaceDraft = {
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: "shared_workspace",
|
||||||
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||||
|
};
|
||||||
|
issueWorkspaceBranchName = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -155,6 +169,7 @@ describe("RoutineRunVariablesDialog", () => {
|
|||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
|
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
|
||||||
@@ -166,4 +181,87 @@ describe("RoutineRunVariablesDialog", () => {
|
|||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders workspaceBranch as a read-only selected workspace value", async () => {
|
||||||
|
issueWorkspaceDraft = {
|
||||||
|
executionWorkspaceId: "workspace-1",
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||||
|
};
|
||||||
|
issueWorkspaceBranchName = "pap-1634-routine-branch";
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RoutineRunVariablesDialog
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
companyId="company-1"
|
||||||
|
projects={[createProject()]}
|
||||||
|
agents={[createAgent()]}
|
||||||
|
defaultProjectId="project-1"
|
||||||
|
defaultAssigneeAgentId="agent-1"
|
||||||
|
variables={[
|
||||||
|
{
|
||||||
|
name: "workspaceBranch",
|
||||||
|
label: null,
|
||||||
|
type: "text",
|
||||||
|
defaultValue: null,
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isPending={false}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 10 && !document.querySelector('[data-testid="workspace-card"]'); i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchInput = Array.from(document.querySelectorAll("input"))
|
||||||
|
.find((input) => input.value === "pap-1634-routine-branch");
|
||||||
|
expect(branchInput?.disabled).toBe(true);
|
||||||
|
expect(document.body.textContent).not.toContain("Missing: workspaceBranch");
|
||||||
|
|
||||||
|
const runButton = Array.from(document.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent === "Run routine");
|
||||||
|
expect(runButton).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
variables: {
|
||||||
|
workspaceBranch: "pap-1634-routine-branch",
|
||||||
|
},
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
executionWorkspaceId: "workspace-1",
|
||||||
|
executionWorkspacePreference: "reuse_existing",
|
||||||
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { Agent, IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
|
import {
|
||||||
|
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||||
|
type Agent,
|
||||||
|
type IssueExecutionWorkspaceSettings,
|
||||||
|
type Project,
|
||||||
|
type RoutineVariable,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
@@ -189,6 +195,7 @@ export function RoutineRunVariablesDialog({
|
|||||||
: null;
|
: null;
|
||||||
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
|
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
|
||||||
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
||||||
|
const [workspaceBranchName, setWorkspaceBranchName] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: experimentalSettings } = useQuery({
|
const { data: experimentalSettings } = useQuery({
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
@@ -208,15 +215,27 @@ export function RoutineRunVariablesDialog({
|
|||||||
setSelection(nextSelection);
|
setSelection(nextSelection);
|
||||||
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
|
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
|
||||||
setWorkspaceConfigValid(true);
|
setWorkspaceConfigValid(true);
|
||||||
|
setWorkspaceBranchName(null);
|
||||||
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
|
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
|
||||||
|
|
||||||
|
const workspaceBranchAutoValue = workspaceSelectionEnabled && workspaceBranchName
|
||||||
|
? workspaceBranchName
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isAutoWorkspaceBranchVariable = useCallback(
|
||||||
|
(variable: RoutineVariable) =>
|
||||||
|
variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE && Boolean(workspaceBranchAutoValue),
|
||||||
|
[workspaceBranchAutoValue],
|
||||||
|
);
|
||||||
|
|
||||||
const missingRequired = useMemo(
|
const missingRequired = useMemo(
|
||||||
() =>
|
() =>
|
||||||
variables
|
variables
|
||||||
.filter((variable) => variable.required)
|
.filter((variable) => variable.required)
|
||||||
|
.filter((variable) => !isAutoWorkspaceBranchVariable(variable))
|
||||||
.filter((variable) => isMissingRequiredValue(values[variable.name]))
|
.filter((variable) => isMissingRequiredValue(values[variable.name]))
|
||||||
.map((variable) => variable.label || variable.name),
|
.map((variable) => variable.label || variable.name),
|
||||||
[values, variables],
|
[isAutoWorkspaceBranchVariable, values, variables],
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceIssue = useMemo(() => ({
|
const workspaceIssue = useMemo(() => ({
|
||||||
@@ -247,10 +266,14 @@ export function RoutineRunVariablesDialog({
|
|||||||
|
|
||||||
const handleWorkspaceDraftChange = useCallback((
|
const handleWorkspaceDraftChange = useCallback((
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
meta: { canSave: boolean },
|
meta: { canSave: boolean; workspaceBranchName?: string | null },
|
||||||
) => {
|
) => {
|
||||||
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||||
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
|
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
|
||||||
|
setWorkspaceBranchName((current) => {
|
||||||
|
const next = meta.workspaceBranchName ?? null;
|
||||||
|
return current === next ? current : next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -328,6 +351,7 @@ export function RoutineRunVariablesDialog({
|
|||||||
setSelection((current) => ({ ...current, projectId }));
|
setSelection((current) => ({ ...current, projectId }));
|
||||||
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
||||||
setWorkspaceConfigValid(true);
|
setWorkspaceConfigValid(true);
|
||||||
|
setWorkspaceBranchName(null);
|
||||||
}}
|
}}
|
||||||
renderTriggerValue={(option) =>
|
renderTriggerValue={(option) =>
|
||||||
option && selectedProject ? (
|
option && selectedProject ? (
|
||||||
@@ -365,7 +389,13 @@ export function RoutineRunVariablesDialog({
|
|||||||
{variable.label || variable.name}
|
{variable.label || variable.name}
|
||||||
{variable.required ? " *" : ""}
|
{variable.required ? " *" : ""}
|
||||||
</Label>
|
</Label>
|
||||||
{variable.type === "textarea" ? (
|
{isAutoWorkspaceBranchVariable(variable) ? (
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
value={workspaceBranchAutoValue ?? ""}
|
||||||
|
/>
|
||||||
|
) : variable.type === "textarea" ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""}
|
value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""}
|
||||||
@@ -450,6 +480,10 @@ export function RoutineRunVariablesDialog({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const nextVariables: Record<string, string | number | boolean> = {};
|
const nextVariables: Record<string, string | number | boolean> = {};
|
||||||
for (const variable of variables) {
|
for (const variable of variables) {
|
||||||
|
if (isAutoWorkspaceBranchVariable(variable)) {
|
||||||
|
nextVariables[variable.name] = workspaceBranchAutoValue!;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const rawValue = values[variable.name];
|
const rawValue = values[variable.name];
|
||||||
if (isMissingRequiredValue(rawValue)) continue;
|
if (isMissingRequiredValue(rawValue)) continue;
|
||||||
if (variable.type === "number") {
|
if (variable.type === "number") {
|
||||||
|
|||||||
@@ -709,7 +709,7 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Runtime config</div>
|
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Runtime config</div>
|
||||||
<div className="rounded-md border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
<div className="rounded-md border border-dashed border-border/70 bg-background px-4 py-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium text-foreground">
|
<div className="text-sm font-medium text-foreground">
|
||||||
@@ -741,7 +741,7 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details className="rounded-md border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
<details className="rounded-md border border-dashed border-border/70 bg-background px-4 py-3">
|
||||||
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
|
Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
|
||||||
@@ -913,7 +913,7 @@ export function ExecutionWorkspaceDetail() {
|
|||||||
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{workspaceOperationsQuery.data.map((operation) => (
|
{workspaceOperationsQuery.data.map((operation) => (
|
||||||
<div key={operation.id} className="rounded-md border border-border/80 bg-muted/30 px-4 py-3">
|
<div key={operation.id} className="rounded-md border border-border/80 bg-background px-4 py-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
||||||
|
|||||||
@@ -545,7 +545,7 @@ export function ProjectWorkspaceDetail() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
<details className="rounded-xl border border-dashed border-border/70 bg-background px-3 py-3">
|
||||||
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Paperclip derives Services and Jobs from this JSON. Prefer editing named commands first; use raw JSON for advanced lifecycle, port, readiness, or environment settings.
|
Paperclip derives Services and Jobs from this JSON. Prefer editing named commands first; use raw JSON for advanced lifecycle, port, readiness, or environment settings.
|
||||||
|
|||||||
Reference in New Issue
Block a user