feat: add Ops AI Workspace npm setup command

This commit is contained in:
DCCONSTRUCTIONS 2026-06-24 20:40:12 +03:00
parent 763b90f3ba
commit 43e5f570e5
7 changed files with 160 additions and 31 deletions

View File

@ -10,6 +10,7 @@ from plane.app.views import (
AIWorkspaceExecutorEventsEndpoint, AIWorkspaceExecutorEventsEndpoint,
AIWorkspaceExecutorListEndpoint, AIWorkspaceExecutorListEndpoint,
AIWorkspaceExecutorSelectEndpoint, AIWorkspaceExecutorSelectEndpoint,
AIWorkspaceExecutorSetupCommandEndpoint,
AIWorkspaceExecutorWindowsAgentEndpoint, AIWorkspaceExecutorWindowsAgentEndpoint,
AIWorkspaceSettingsEndpoint, AIWorkspaceSettingsEndpoint,
AIWorkspaceThreadDispatchEndpoint, AIWorkspaceThreadDispatchEndpoint,
@ -55,6 +56,11 @@ urlpatterns = [
AIWorkspaceExecutorWindowsAgentEndpoint.as_view(), AIWorkspaceExecutorWindowsAgentEndpoint.as_view(),
name="ai-workspace-executor-windows-agent", name="ai-workspace-executor-windows-agent",
), ),
path(
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/agent/setup-command/",
AIWorkspaceExecutorSetupCommandEndpoint.as_view(),
name="ai-workspace-executor-setup-command",
),
path( path(
"workspaces/<str:slug>/ai-workspace/threads/", "workspaces/<str:slug>/ai-workspace/threads/",
AIWorkspaceThreadListEndpoint.as_view(), AIWorkspaceThreadListEndpoint.as_view(),

View File

@ -180,6 +180,7 @@ from .ai_workspace import (
AIWorkspaceExecutorEventsEndpoint, AIWorkspaceExecutorEventsEndpoint,
AIWorkspaceExecutorListEndpoint, AIWorkspaceExecutorListEndpoint,
AIWorkspaceExecutorSelectEndpoint, AIWorkspaceExecutorSelectEndpoint,
AIWorkspaceExecutorSetupCommandEndpoint,
AIWorkspaceExecutorWindowsAgentEndpoint, AIWorkspaceExecutorWindowsAgentEndpoint,
AIWorkspaceSettingsEndpoint, AIWorkspaceSettingsEndpoint,
AIWorkspaceThreadDispatchEndpoint, AIWorkspaceThreadDispatchEndpoint,

View File

@ -505,6 +505,51 @@ def ops_settings_payload(raw_payload, slug):
return payload return payload
def sync_ops_installer_context(request, slug, source):
request_data = request.data if isinstance(request.data, dict) else {}
ops_project_id = str(
request_data.get("opsProjectId")
or request_data.get("ops_project_id")
or request.query_params.get("ops_project_id")
or ""
).strip()
if not ops_project_id:
return None
ops_workspace_slug = str(
request_data.get("opsWorkspaceSlug")
or request_data.get("ops_workspace_slug")
or request.query_params.get("ops_workspace_slug")
or slug
or ""
).strip()
active_context = {
"surface": "ops",
"workspaceSlug": ops_workspace_slug,
"opsWorkspaceSlug": ops_workspace_slug,
"opsRouteWorkspaceSlug": slug,
"opsProjectId": ops_project_id,
}
sync_payload = ops_settings_payload(
{
"activeContext": active_context,
"enabledToolPacks": ["ops", "engine"],
"metadata": {
"source": source,
"updatedAt": timezone.now().isoformat(),
},
},
slug,
)
sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload)
if ops_grant_error is not None:
return ops_grant_error
sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload)
if sync_response.status_code >= 400:
return sync_response
return None
class AIWorkspaceSettingsEndpoint(BaseAPIView): class AIWorkspaceSettingsEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug): def get(self, request, slug):
@ -584,33 +629,9 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
params = {} params = {}
if request.query_params.get("port"): if request.query_params.get("port"):
params["port"] = request.query_params.get("port") params["port"] = request.query_params.get("port")
ops_project_id = (request.query_params.get("ops_project_id") or "").strip() sync_error = sync_ops_installer_context(request, slug, "ops-ai-workspace-installer")
if ops_project_id: if sync_error is not None:
ops_workspace_slug = (request.query_params.get("ops_workspace_slug") or slug or "").strip() return sync_error
active_context = {
"surface": "ops",
"workspaceSlug": ops_workspace_slug,
"opsWorkspaceSlug": ops_workspace_slug,
"opsRouteWorkspaceSlug": slug,
"opsProjectId": ops_project_id,
}
sync_payload = ops_settings_payload(
{
"activeContext": active_context,
"enabledToolPacks": ["ops", "engine"],
"metadata": {
"source": "ops-ai-workspace-installer",
"updatedAt": timezone.now().isoformat(),
},
},
slug,
)
sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload)
if ops_grant_error is not None:
return ops_grant_error
sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload)
if sync_response.status_code >= 400:
return sync_response
try: try:
response = requests.get( response = requests.get(
@ -643,6 +664,27 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
return output return output
class AIWorkspaceExecutorSetupCommandEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, executor_id):
sync_error = sync_ops_installer_context(request, slug, "ops-ai-workspace-npm-setup")
if sync_error is not None:
return sync_error
request_data = request.data if isinstance(request.data, dict) else {}
payload = {}
port = request_data.get("port") or request.query_params.get("port")
if port:
payload["port"] = port
return ai_workspace_request(
request,
"POST",
f"/executors/{executor_id}/agent/setup-command",
payload,
timeout_override=15,
)
class AIWorkspaceThreadListEndpoint(BaseAPIView): class AIWorkspaceThreadListEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug): def get(self, request, slug):

View File

@ -1547,6 +1547,29 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW
[opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug] [opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug]
); );
const copySetupCommand = useCallback(
async (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => {
await runRegistryAction(
"setup-command",
async () => {
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input);
await updateRegistry(payload);
const setup = await workspaceAIWorkspaceService.createAgentSetupCommand(workspaceSlug, executorId, {
port,
opsWorkspaceSlug,
opsProjectId: selectedProjectId,
});
const command = String(setup.install?.command || "").trim();
if (!command) throw new Error("setup_command_empty");
if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable");
await navigator.clipboard.writeText(command);
},
"npm setup command скопирована"
);
},
[opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug]
);
const createThread = async () => { const createThread = async () => {
try { try {
setThreadError(""); setThreadError("");
@ -2236,6 +2259,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW
onDelete={deleteExecutor} onDelete={deleteExecutor}
onOpsWorkspaceChange={setSelectedOpsWorkspaceSlug} onOpsWorkspaceChange={setSelectedOpsWorkspaceSlug}
onProjectChange={setSelectedProjectId} onProjectChange={setSelectedProjectId}
onCopySetupCommand={copySetupCommand}
onDownloadAgent={downloadAgent} onDownloadAgent={downloadAgent}
getWindowsAgentInstallerUrl={(executorId, options) => getWindowsAgentInstallerUrl={(executorId, options) =>
workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, { workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, {

View File

@ -68,6 +68,7 @@ type TAIWorkspaceProductSettingsModalProps = {
onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>; onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>;
onOpsWorkspaceChange: (workspaceSlug: string) => void; onOpsWorkspaceChange: (workspaceSlug: string) => void;
onProjectChange: (projectId: string) => void; onProjectChange: (projectId: string) => void;
onCopySetupCommand: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>; onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
getWindowsAgentInstallerUrl: ( getWindowsAgentInstallerUrl: (
executorId: string, executorId: string,
@ -324,6 +325,7 @@ export function AIWorkspaceProductSettingsModal({
onDelete, onDelete,
onOpsWorkspaceChange, onOpsWorkspaceChange,
onProjectChange, onProjectChange,
onCopySetupCommand,
onDownloadAgent, onDownloadAgent,
getWindowsAgentInstallerUrl, getWindowsAgentInstallerUrl,
}: TAIWorkspaceProductSettingsModalProps) { }: TAIWorkspaceProductSettingsModalProps) {
@ -450,6 +452,11 @@ export function AIWorkspaceProductSettingsModal({
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT); await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
}; };
const copySetupCommand = async () => {
if (!editingExecutor) return;
await onCopySetupCommand(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
};
const activeCheckDetails = activeExecutor const activeCheckDetails = activeExecutor
? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ") ? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ")
: ""; : "";
@ -696,18 +703,26 @@ export function AIWorkspaceProductSettingsModal({
placeholder="XXXX-XXXX-XXXX" placeholder="XXXX-XXXX-XXXX"
/> />
</label> </label>
<button
type="button"
className="modal-btn ai-settings-download ai-settings-inline-download btn-primary"
disabled={isBusy}
onClick={() => void copySetupCommand()}
>
Copy npx
</button>
<a <a
className="modal-btn ai-settings-download ai-settings-inline-download" className="modal-btn ai-settings-download ai-settings-inline-download"
href={getWindowsAgentInstallerUrl(editingExecutor.id)} href={getWindowsAgentInstallerUrl(editingExecutor.id)}
onClick={downloadAgent} onClick={downloadAgent}
download download
> >
Скачать агент Legacy .ps1
</a> </a>
</div> </div>
) : draft.connectionMode === "hub" ? ( ) : draft.connectionMode === "hub" ? (
<div className="ai-settings-check-note"> <div className="ai-settings-check-note">
После сохранения общий AI Workspace создаст pairing code и installer агента. После сохранения общий AI Workspace создаст pairing code и npm setup command.
</div> </div>
) : null} ) : null}

View File

@ -139,6 +139,21 @@ export type TAIWorkspaceBridgeEventsResponse = {
events: TAIWorkspaceBridgeEvent[]; events: TAIWorkspaceBridgeEvent[];
}; };
export type TAIWorkspaceAgentSetupCommandResponse = {
ok: boolean;
executorId?: string;
install?: {
command?: string;
packageName?: string;
gatewayUrl?: string;
expiresAt?: string;
};
setupCode?: {
suffix?: string;
expiresAt?: string;
};
};
export type TAIWorkspaceOpsProject = { export type TAIWorkspaceOpsProject = {
id: string; id: string;
identifier?: string | null; identifier?: string | null;
@ -284,6 +299,22 @@ export class WorkspaceAIWorkspaceService extends APIService {
return this.listExecutors(workspaceSlug); return this.listExecutors(workspaceSlug);
} }
async createAgentSetupCommand(
workspaceSlug: string,
executorId: string,
options: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string } = {}
): Promise<TAIWorkspaceAgentSetupCommandResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/agent/setup-command/`, {
port: String(options.port || "").trim() || undefined,
opsWorkspaceSlug: String(options.opsWorkspaceSlug || "").trim() || undefined,
opsProjectId: String(options.opsProjectId || "").trim() || undefined,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
getWindowsAgentInstallerUrl( getWindowsAgentInstallerUrl(
workspaceSlug: string, workspaceSlug: string,
executorId: string, executorId: string,

View File

@ -1163,7 +1163,7 @@
.ai-settings-field-row--download { .ai-settings-field-row--download {
align-items: end; align-items: end;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) minmax(96px, 112px) auto;
} }
.ai-settings-field > span { .ai-settings-field > span {
@ -1409,7 +1409,7 @@
.ai-settings-actions .modal-btn:hover:not(:disabled), .ai-settings-actions .modal-btn:hover:not(:disabled),
.ai-settings-empty-context .modal-btn:hover:not(:disabled), .ai-settings-empty-context .modal-btn:hover:not(:disabled),
.ai-settings-inline-download:hover { .ai-settings-inline-download:hover:not(:disabled) {
background: var(--nodedc-glass-control-hover); background: var(--nodedc-glass-control-hover);
color: #ffffff; color: #ffffff;
} }
@ -1420,11 +1420,21 @@
color: var(--brand-contrast); color: var(--brand-contrast);
} }
.ai-settings-inline-download.btn-primary {
background: var(--brand);
color: var(--brand-contrast);
}
.ai-settings-actions .modal-btn:disabled { .ai-settings-actions .modal-btn:disabled {
cursor: default; cursor: default;
opacity: 0.48; opacity: 0.48;
} }
.ai-settings-inline-download:disabled {
cursor: default;
opacity: 0.48;
}
.ai-settings-context-pane > .ai-settings-actions, .ai-settings-context-pane > .ai-settings-actions,
.ai-settings-details-pane > .ai-settings-actions { .ai-settings-details-pane > .ai-settings-actions {
margin-top: auto; margin-top: auto;