Cloud Run의 Node.js로 맞춤 Google Workspace Studio 단계 빌드

1. 소개

Google Workspace Studio 를 사용하면 Gmail, Google Chat, Google Drive, Google Sheets와 같은 Google Workspace 애플리케이션 전반에서 비즈니스 프로세스를 자동화할 수 있습니다. 워크플로를 외부 웹 애플리케이션에서 시작하거나 자체 클라우드 환경에서 커스텀 로직을 실행해야 하는 경우 Cloud Run의 Node.js에서 HTTP Google Workspace 부가기능으로 Google Workspace Studio를 확장할 수 있습니다.

  1. 커스텀 스타터 단계 (workflowTrigger): 트리거의 notifyUri (또는 Google Workspace Studio triggers.fire REST API)를 호출하여 고객이 내부 웹 포털에서 티켓을 제출하는 등의 외부 이벤트가 발생할 때 Google Workspace Studio 플로우를 시작합니다.
  2. 맞춤 액션 단계 (workflowAction): 플로우의 이전 단계에서 변수를 수신하고, Node.js 서비스에서 맞춤 로직 또는 AI 추론을 실행하고, 출력 변수를 Google Workspace Studio로 다시 반환합니다.

이 Codelab에서는 Cloud Run에서 Google Workspace Studio용 완전한 Node.js HTTP 확장 프로그램을 빌드 및 배포하고, Cloud Firestore를 사용하여 컨테이너 버전 전반에서 활성 플로우 트리거 구독 및 제출된 티켓을 유지하고, Cloud Tasks를 사용하여 notifyUri가 성공 응답을 반환하지 않는 경우 트리거 알림을 자동으로 재시도하고, 복원력이 있는 키워드 일치 대체 기능을 사용하여 Gemini Enterprise Agent Platform의 Gemini 3.8 Flash (gemini-3.8-flash)를 사용하여 맞춤 액션 단계에서 티켓 긴급도를 분류합니다.

빌드할 항목

  • Cloud Run 의 Node.js, Express, Firestore, Cloud Tasks 로 빌드된 지원 빠른 포털 웹 앱 및 HTTP Google Workspace 부가기능
  • studio_triggers Firestore 컬렉션에서 활성 플로우 구독을 유지하고, support_tickets Firestore 컬렉션에서 제출된 티켓을 로깅하고, 웹 포털에서 티켓이 제출될 때마다 실행되어 ticketTitle 및 ticketDescription을 Google Workspace Studio 플로우로 전달하는 새 지원 티켓 커스텀 스타터 단계 (supportTicketTrigger)
  • Firestore에 저장된 최신 userOAuthToken을 사용하여 지수 백오프로 실패한 트리거 호출을 대기열에 추가하고 재시도하여 notifyUri로의 전송을 보장하는 Cloud Tasks 재시도 작업자 (studio-trigger-retries 큐 및 POST /api/tasks/fire-trigger)
  • Gemini Enterprise Agent Platform (aiplatform.googleapis.com)에서 Gemini 3.8 Flash (gemini-3.8-flash)를 사용하여 티켓 긴급도를 분류하고 키워드 검색 유형 (urgent, blocker, critical)으로 자동 대체하는 긴급도 감지 맞춤 액션 단계 (urgencyDetectorStep)

아키텍처

다음 다이어그램은 플로우 등록부터 복원력이 있는 트리거 전송 및 작업 실행까지 지원 빠른 포털, Cloud Run의 Node.js 부가기능, Cloud Firestore, Cloud Tasks (studio-trigger-retries), Gemini 3.8 Flash, Google Workspace Studio가 상호작용하는 방식을 보여줍니다.

Firestore, Cloud Tasks, Gemini 3.8 Flash를 사용하는 Google Workspace Studio Node.js 부가기능 아키텍처

학습할 내용

  • workflowTrigger 및 workflowAction 요소로 HTTP Google Workspace 부가기능 배포 매니페스트 (deployment.json)를 구성하는 방법
  • studio_triggers Firestore 컬렉션에서 Google Workspace Studio 트리거 구독 (triggerId, notifyUri, userOAuthToken)을 유지하고 notifyUri / triggers.fire를 사용하여 플로우를 실행하는 방법
  • Cloud Tasks (@google-cloud/tasks)를 사용하여 notifyUri가 2xx 성공 응답을 반환할 때까지 지수 백오프로 실패한 트리거 알림을 재시도하는 방법
  • 키워드 대체 기능을 사용하여 Gemini Enterprise Agent Platform에서 Gemini 3.8 Flash (gemini-3.8-flash) 구조화된 출력을 사용하여 Node.js에서 workflowAction 커스텀 단계를 빌드하는 방법

기본 요건

  • Google Workspace Studio에 액세스할 수 있는 Google Workspace 계정
  • 테스트를 위해 게시되지 않은 부가기능 배포를 설치할 수 있도록 사용자 계정과 동일한 Google Workspace 조직 내에서 생성되고 결제가 사용 설정된 Google Cloud 프로젝트

2. Google Cloud 프로젝트, Firestore, Cloud Tasks 설정

이 단계에서는 프로젝트의 OAuth 동의 화면을 구성하고, Cloud Shell에서 필요한 Google Cloud 및 Google Workspace API를 사용 설정하고, (default) Firestore 데이터베이스를 프로비저닝하고, Cloud Tasks 재시도 큐를 만들고, 부가기능 승인 서비스 계정을 초기화합니다.

  1. Google Cloud 콘솔을 열고 Google Cloud 프로젝트를 선택한 후 상단 탐색 메뉴에서 Cloud Shell 활성화 아이콘을 클릭합니다.
  2. Google Workspace에서 부가기능의 OAuth 클라이언트를 프로비저닝할 수 있도록 OAuth 동의 화면 을 구성합니다.
    1. Google 인증 플랫폼 브랜딩 페이지를 엽니다.
    2. 메시지가 표시되면 시작하기 를 클릭합니다. (동의 화면이 이미 구성되어 있다면 3단계로 건너뜁니다.)
    3. 앱 정보에서 Support Extension을(를) 앱 이름에 입력하고 사용자 지원 이메일을 선택한 후 다음을 클릭합니다.
    4. 잠재고객에서 내부를 선택하고 다음을 클릭합니다.
    5. 연락처 정보 에서 이메일 주소를 입력하고 다음 을 클릭합니다.
    6. 완료에서 Google API 서비스: 사용자 데이터 정책에 동의하고 계속을 클릭한 후 만들기를 클릭합니다.
  3. Cloud Shell에서 활성 프로젝트를 설정하고 (Google Cloud 프로젝트 ID로 YOUR_PROJECT_ID 대체) 필요한 API를 사용 설정합니다.
    gcloud config set project YOUR_PROJECT_ID
    gcloud services enable \
      aiplatform.googleapis.com \
      firestore.googleapis.com \
      cloudtasks.googleapis.com \
      run.googleapis.com \
      cloudbuild.googleapis.com \
      artifactregistry.googleapis.com \
      appsmarket-component.googleapis.com \
      gsuiteaddons.googleapis.com \
      workspacestudio.googleapis.com
    
  4. us-central1에서 (default) Firestore 데이터베이스 및 studio-trigger-retries Cloud Tasks 큐를 프로비저닝하고 (아직 없는 경우) Google Workspace 부가기능 서비스 계정을 초기화합니다.
    gcloud firestore databases describe --database="(default)" >/dev/null 2>&1 || \
      gcloud firestore databases create \
        --database="(default)" \
        --location=us-central1 \
        --type=firestore-native \
        --quiet
    
    gcloud tasks queues describe studio-trigger-retries --location=us-central1 >/dev/null 2>&1 || \
      gcloud tasks queues create studio-trigger-retries \
        --location=us-central1 \
        --max-attempts=5 \
        --min-backoff=10s \
        --max-backoff=300s \
        --max-doublings=4
    
    gcloud workspace-add-ons get-authorization
    

3. Firestore, Cloud Tasks, Gemini 3.8 Flash로 Node.js 앱 빌드

이 단계에서는 ~/studio-support-addon에서 Node.js 애플리케이션을 빌드합니다. 코드를 모듈화하고 읽기 쉽게 유지하기 위해 프로젝트를 6개의 집중된 파일로 구성합니다.

  • firestore.js: Cloud Firestore 클라이언트 (studio_triggers 및 support_tickets 컬렉션)를 초기화하고 캐시된 OAuth 토큰을 새로고침합니다.
  • starter.js: 새 지원 티켓 커스텀 스타터 단계 웹훅 (/onConfigTrigger 및 /onManageTrigger)을 구현합니다.
  • action.js: 키워드 일치 대체 기능을 사용하여 Gemini 3.8 Flash (gemini-3.8-flash)를 사용하여 긴급도 감지 맞춤 액션 단계 웹훅 (/onConfigUrgency 및 /onExecuteUrgency)을 구현합니다.
  • tasks.js: 트리거 알림을 trigger.notifyUri로 디스패치하고 (https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire로 대체) 실패한 전송을 studio-trigger-retries Cloud Tasks 큐에 대기열에 추가하고 POST /api/tasks/fire-trigger 재시도 작업자 엔드포인트를 구현합니다.
  • portal.js: 지원 빠른 포털 웹 UI (GET /)를 제공하고 활성 Google Workspace Studio 트리거 (POST /api/submit-ticket)를 실행하며, 일시적인 실패를 시뮬레이션하고 Cloud Tasks 재시도를 볼 수 있는 옵션이 있습니다.
  • index.js: 모든 Express 경로를 함께 연결하고 HTTP 서버를 시작합니다.

프로젝트 및 package.json 초기화

  1. 프로젝트 디렉터리를 만들고 디렉터리로 이동합니다.
    mkdir -p ~/studio-support-addon && cd ~/studio-support-addon
    
  2. express, @google-cloud/firestore, @google-cloud/tasks, @google/genai로 package.json을 만듭니다.
    cat << 'EOF' > package.json
    {
      "name": "studio-support-addon",
      "version": "1.0.0",
      "type": "module",
      "main": "index.js",
      "scripts": {
        "start": "node index.js"
      },
      "dependencies": {
        "@google-cloud/firestore": "^7.11.0",
        "@google-cloud/tasks": "^5.6.0",
        "@google/genai": "^1.0.0",
        "express": "^4.21.2"
      }
    }
    EOF
    

firestore.js — 공유 Firestore 상태 및 OAuth 토큰 새로고침

firestore.js를 만들어 두 개의 Firestore 컬렉션을 관리하고 캐시된 사용자 OAuth 토큰을 최신 상태로 유지합니다.

  • studio_triggers: Cloud Run 컨테이너 다시 시작 및 Scale-to-zero 이벤트에서 활성 Google Workspace Studio 플로우 구독 (triggerId, notifyUri, userOAuthToken)을 저장합니다.
  • support_tickets: 포털을 통해 제출된 각 지원 티켓의 기록을 저장합니다.
  • refreshTokenInFirestore(req): Google Workspace Studio가 웹훅 엔드포인트를 호출할 때마다 수명이 짧은 authorizationEventObject.userOAuthToken이 포함됩니다. 이 도우미는 백그라운드 triggers.fire 호출이 승인된 상태로 유지되도록 최신 토큰으로 활성 트리거 문서를 업데이트합니다.
cat << 'EOF' > firestore.js
import { Firestore } from '@google-cloud/firestore';

export const db = new Firestore({
  projectId: process.env.GOOGLE_CLOUD_PROJECT || undefined,
  databaseId: '(default)',
  ignoreUndefinedProperties: true,
});

export const triggersCollection = db.collection(
  process.env.FIRESTORE_TRIGGERS_COLLECTION || 'studio_triggers'
);

export const ticketsCollection = db.collection(
  process.env.FIRESTORE_TICKETS_COLLECTION || 'support_tickets'
);

// Keep cached userOAuthToken fresh across active trigger documents
export async function refreshTokenInFirestore(req) {
  const freshToken = req.body?.authorizationEventObject?.userOAuthToken;
  if (!freshToken) return;
  try {
    const snapshot = await triggersCollection.get();
    if (snapshot.empty) return;
    const batch = db.batch();
    snapshot.docs.forEach((doc) => {
      batch.set(
        doc.ref,
        { userOAuthToken: freshToken, updatedAt: new Date().toISOString() },
        { merge: true }
      );
    });
    await batch.commit();
  } catch (err) {
    console.error('Error refreshing OAuth token in Firestore:', err.message);
  }
}
EOF

starter.js — 커스텀 스타터 단계 (workflowTrigger)

starter.js를 만들어 Google Workspace Studio 커스텀 스타터 단계에 필요한 두 개의 웹훅 엔드포인트를 구현합니다.

  • onConfigTrigger (POST /onConfigTrigger): 사용자가 Google Workspace Studio에서 플로우의 스타터로 새 지원 티켓을 선택할 때 호출됩니다. 스타터를 설명하는 pushCard 탐색 작업을 반환합니다.
  • onManageTrigger (POST /onManageTrigger): 사용자가 플로우를 사용 설정 (req.body.workflow.triggerCreation)하거나 사용 중지 / 삭제 (req.body.workflow.triggerDeletion)할 때 호출됩니다. studio_triggers Firestore 컬렉션에서 구독 문서를 저장하거나 삭제합니다.
cat << 'EOF' > starter.js
import { triggersCollection, refreshTokenInFirestore } from './firestore.js';

// Custom Starter Configuration Card
export async function onConfigTrigger(req, res) {
  await refreshTokenInFirestore(req);
  res.status(200).json({
    action: {
      navigations: [
        {
          pushCard: {
            sections: [
              {
                header: 'Support Ticket Listener',
                widgets: [
                  {
                    textParagraph: {
                      text: 'This starter listens for new tickets submitted through the Support Quick Portal and stores subscriptions in Firestore.',
                    },
                  },
                ],
              },
            ],
          },
        },
      ],
    },
  });
}

// Custom Starter Subscription Lifecycle (Firestore studio_triggers collection)
export async function onManageTrigger(req, res) {
  try {
    const triggerCreation = req.body?.workflow?.triggerCreation;
    const triggerDeletion = req.body?.workflow?.triggerDeletion;
    const userOAuthToken =
      req.body?.authorizationEventObject?.userOAuthToken || null;

    if (triggerCreation?.triggerId) {
      const triggerId = String(triggerCreation.triggerId);
      const docId = encodeURIComponent(triggerId);
      await triggersCollection.doc(docId).set(
        {
          triggerId,
          notifyUri: triggerCreation.notifyUri || null,
          ...(userOAuthToken ? { userOAuthToken } : {}),
          updatedAt: new Date().toISOString(),
        },
        { merge: true }
      );
    }

    if (triggerDeletion?.triggerId) {
      const triggerId = String(triggerDeletion.triggerId);
      const docId = encodeURIComponent(triggerId);
      await triggersCollection.doc(docId).delete();
    }

    if (!triggerCreation && !triggerDeletion && userOAuthToken) {
      await refreshTokenInFirestore(req);
    }
  } catch (err) {
    console.error('Error in /onManageTrigger:', err.message);
  }
  res.status(200).json({});
}
EOF

action.js — Gemini 3.8 Flash가 포함된 맞춤 액션 단계 (workflowAction)

action.js를 만들어 Google Workspace Studio 맞춤 액션 단계에 필요한 두 개의 웹훅 엔드포인트를 구현합니다.

  • onConfigUrgency (POST /onConfigUrgency): 스타터 단계에서 출력된 ticketDescription 변수를 선택할 수 있도록 hostAppDataSource.workflowDataSource.includeVariables: true를 설정하는 textInput 위젯이 포함된 구성 카드를 반환합니다.
  • onExecuteUrgency (POST /onExecuteUrgency): req.body.workflow.actionInvocation.inputs에서 ticketDescription 입력을 읽고, 구조화된 출력 (responseSchema enum ["High", "Normal"])을 사용하여 Gemini Enterprise Agent Platform에서 Gemini 3.8 Flash (gemini-3.8-flash)를 사용하여 비즈니스 영향 및 긴급도를 분류하고, 모델 호출이 실패하면 detectKeywordUrgency() (urgent, blocker, critical)로 대체하고, hostAppAction.workflowAction.returnOutputVariablesAction을 사용하여 urgencyLevel 출력 변수를 반환합니다.
cat << 'EOF' > action.js
import { GoogleGenAI, Type } from '@google/genai';
import { refreshTokenInFirestore } from './firestore.js';

// Initialize Google GenAI SDK for the Gemini Enterprise Agent Platform
const ai = new GoogleGenAI({
  vertexai: true,
  project: process.env.GOOGLE_CLOUD_PROJECT,
  location: 'global',
});

// Fallback keyword classifier if the Gemini API call is unavailable
export function detectKeywordUrgency(description) {
  const lower = (description || '').toLowerCase();
  return lower.includes('urgent') ||
    lower.includes('blocker') ||
    lower.includes('critical')
    ? 'High'
    : 'Normal';
}

// Custom Action Step Configuration Card
export async function onConfigUrgency(req, res) {
  await refreshTokenInFirestore(req);
  res.status(200).json({
    action: {
      navigations: [
        {
          pushCard: {
            sections: [
              {
                header: 'Urgency Detector Step',
                widgets: [
                  {
                    textInput: {
                      name: 'ticketDescription',
                      label: 'Source Description',
                      hintText:
                        'Select the description variable outputted by the starter',
                      hostAppDataSource: {
                        workflowDataSource: {
                          includeVariables: true,
                        },
                      },
                    },
                  },
                ],
              },
            ],
          },
        },
      ],
    },
  });
}

// Custom Action Step Execution: Gemini 3.8 Flash with keyword fallback
export async function onExecuteUrgency(req, res) {
  await refreshTokenInFirestore(req);
  const inputs = req.body?.workflow?.actionInvocation?.inputs || {};
  const description = inputs.ticketDescription?.stringValues?.[0] || '';

  let urgencyLevel = detectKeywordUrgency(description);
  try {
    const response = await ai.models.generateContent({
      model: 'gemini-3.8-flash',
      contents: `Evaluate the business impact and time sensitivity of this support ticket description and classify its urgency:\n\n${description}`,
      config: {
        responseMimeType: 'text/x.enum',
        responseSchema: {
          type: Type.STRING,
          enum: ['High', 'Normal'],
        },
      },
    });
    const candidate = (response.text || '').trim();
    if (candidate === 'High' || candidate === 'Normal') {
      urgencyLevel = candidate;
    }
  } catch (err) {
    console.error('Gemini fallback to keyword matching:', err.message);
  }

  res.status(200).json({
    hostAppAction: {
      workflowAction: {
        returnOutputVariablesAction: {
          variables: {
            urgencyLevel: {
              stringValues: [urgencyLevel],
            },
          },
        },
      },
    },
  });
}
EOF

tasks.js — Cloud Tasks 재시도 큐 및 작업자 엔드포인트

tasks.js를 만들어 트리거 호출을 trigger.notifyUri로 디스패치하고 (https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire로 대체) 실패한 호출을 studio-trigger-retries Cloud Tasks 큐에 대기열에 추가하고 POST /api/tasks/fire-trigger에서 재시도를 처리합니다.

  • resolveTriggerUrl(trigger): triggerCreation.notifyUri에서 유지된 trigger.notifyUri를 사용하거나 https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire로 대체합니다.
  • fireTriggerDirect(trigger, payload): ticketTitle 및 ticketDescription 출력 변수와 멱등 requestId를 사용하여 트리거 URL에 POST 요청을 보냅니다.
  • enqueueTriggerRetry(req, payload): 초기 트리거 호출이 실패하거나 2xx가 아닌 HTTP 상태를 반환할 때마다 Cloud Run 서비스의 POST /api/tasks/fire-trigger 엔드포인트를 타겟팅하는 HTTP 작업을 studio-trigger-retries 큐에 대기열에 추가합니다.
  • handleFireTriggerTask (POST /api/tasks/fire-trigger): 각 재시도 시도에서 Cloud Tasks 에 의해 호출됩니다. Firestore의 studio_triggers에서 최신 trigger 문서를 조회하고 (재시도에서 항상 최신 userOAuthToken을 사용하도록 보장), X-CloudTasks-TaskRetryCount 헤더를 확인하고 (simulateFailure가 사용 설정된 경우 첫 번째 Cloud Task 실행이 503으로 실패하므로 Cloud Tasks가 백오프하고 재시도 #1에서 성공하는 것을 볼 수 있음), notifyUri가 2xx 성공 응답을 반환하는 경우에만 200 OK를 반환합니다. 모든 5xx 응답은 Cloud Tasks에 큐의 백오프 정책에 따라 다시 시도하도록 지시합니다.
cat << 'EOF' > tasks.js
import { CloudTasksClient } from '@google-cloud/tasks';
import { triggersCollection } from './firestore.js';

const tasksClient = new CloudTasksClient();
const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const LOCATION = process.env.CLOUD_TASKS_LOCATION || 'us-central1';
const QUEUE = process.env.CLOUD_TASKS_QUEUE || 'studio-trigger-retries';

// Prefer trigger.notifyUri from triggerCreation, falling back to triggers.fire
export function resolveTriggerUrl(trigger) {
  return (
    trigger.notifyUri ||
    `https://workspacestudio.googleapis.com/v1/triggers/${encodeURIComponent(trigger.triggerId)}:fire`
  );
}

// Call the Google Workspace Studio trigger URL directly
export async function fireTriggerDirect(
  trigger,
  { title, description, requestId }
) {
  const targetUrl = resolveTriggerUrl(trigger);
  const response = await fetch(targetUrl, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${trigger.userOAuthToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: 'triggers/' + trigger.triggerId,
      outputs: {
        ticketTitle: { stringValues: [title] },
        ticketDescription: { stringValues: [description] },
      },
      requestId,
    }),
  });
  return { ok: response.ok, status: response.status, targetUrl };
}

// Enqueue a retry task in Cloud Tasks targeting POST /api/tasks/fire-trigger
export async function enqueueTriggerRetry(req, payload) {
  const parent = tasksClient.queuePath(PROJECT_ID, LOCATION, QUEUE);
  const baseUrl = process.env.SERVICE_URL || `https://${req.get('host')}`;
  const workerUrl = `${baseUrl}/api/tasks/fire-trigger`;

  const task = {
    httpRequest: {
      httpMethod: 'POST',
      url: workerUrl,
      headers: { 'Content-Type': 'application/json' },
      body: Buffer.from(JSON.stringify(payload)).toString('base64'),
    },
  };

  const [response] = await tasksClient.createTask({ parent, task });
  return response.name;
}

// Cloud Tasks worker endpoint: retries calling notifyUri using fresh Firestore state
export async function handleFireTriggerTask(req, res) {
  const { triggerId, title, description, requestId, simulateFailure } =
    req.body || {};
  const retryCount = Number(req.get('X-CloudTasks-TaskRetryCount') || 0);

  if (!triggerId) {
    return res.status(200).json({ skipped: true, reason: 'Missing triggerId' });
  }

  try {
    const docId = encodeURIComponent(String(triggerId));
    const doc = await triggersCollection.doc(docId).get();
    if (!doc.exists) {
      return res
        .status(200)
        .json({ skipped: true, reason: 'Trigger no longer active' });
    }

    const trigger = doc.data();
    if (!trigger.userOAuthToken) {
      return res
        .status(503)
        .json({ ok: false, error: 'Missing userOAuthToken in Firestore' });
    }

    // When simulateFailure is enabled, fail the 1st Cloud Task attempt (retryCount === 0)
    // so learners can observe Cloud Tasks back off and succeed on retry #1.
    if (simulateFailure && retryCount < 1) {
      console.warn(
        `[Cloud Tasks] Simulated transient failure on initial task attempt (retryCount=${retryCount}) for trigger ${triggerId}`
      );
      return res
        .status(503)
        .json({ ok: false, simulated: true, retryCount, triggerId });
    }

    const outcome = await fireTriggerDirect(trigger, {
      title,
      description,
      requestId,
    });

    if (outcome.ok) {
      console.log(
        `[Cloud Tasks] Successfully delivered trigger ${triggerId} to ${outcome.targetUrl} on retryCount=${retryCount}`
      );
      return res
        .status(200)
        .json({ ok: true, status: outcome.status, retryCount });
    }

    console.error(
      `[Cloud Tasks] Non-2xx response (${outcome.status}) from ${outcome.targetUrl} on retryCount=${retryCount}`
    );
    return res
      .status(502)
      .json({ ok: false, status: outcome.status, retryCount });
  } catch (err) {
    console.error(
      `[Cloud Tasks] Error firing trigger ${triggerId} on retryCount=${retryCount}:`,
      err.message
    );
    return res
      .status(503)
      .json({ ok: false, error: err.message, retryCount });
  }
}
EOF

portal.js — Cloud Tasks 대체 기능이 포함된 지원 빠른 포털 UI 및 notifyUri 디스패처

portal.js를 만들어 웹 포털을 제공하고 자동 Cloud Tasks 재시도 예약으로 티켓 제출을 Google Workspace Studio로 디스패치합니다.

  • renderPortal(GET /): studio_triggers Firestore 컬렉션을 쿼리하여 등록된 활성 Google Workspace Studio 트리거 수를 표시하고 Cloud Tasks 재시도를 테스트하기 위한 일시적인 실패 시뮬레이션 체크박스를 포함한 HTML 티켓 제출 양식을 제공합니다.
  • submitTicket (POST /api/submit-ticket): studio_triggers의 모든 활성 트리거 문서를 반복하고 fireTriggerDirect()를 호출합니다. 트리거 URL이 2xx가 아닌 응답을 반환하거나, 네트워크 오류가 발생하거나, simulateFailure가 사용 설정된 경우 enqueueTriggerRetry()를 사용하여 studio-trigger-retries에서 재시도 작업을 자동으로 대기열에 추가하고 support_tickets Firestore 컬렉션에 티켓을 로깅합니다.
cat << 'EOF' > portal.js
import { triggersCollection, ticketsCollection } from './firestore.js';
import { fireTriggerDirect, enqueueTriggerRetry } from './tasks.js';

// Support Quick Portal UI
export async function renderPortal(req, res) {
  let activeCount = 0;
  try {
    const snapshot = await triggersCollection.get();
    activeCount = snapshot.size;
  } catch (err) {
    console.error('Error reading studio_triggers collection:', err.message);
  }

  res.status(200).send(`<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Support Quick Portal</title>
    <style>
      body { font-family: Arial, sans-serif; padding: 24px; max-width: 540px; margin: auto; color: #202124; }
      .badge { background: #e8f0fe; color: #1967d2; padding: 8px 12px; border-radius: 6px; font-size: 14px; margin-bottom: 16px; }
      label { font-weight: bold; display: block; margin-top: 16px; }
      .checkbox-label { font-weight: normal; margin-top: 14px; display: flex; align-items: center; gap: 8px; cursor: pointer; }
      .checkbox-label input { width: auto; margin: 0; }
      input, textarea { width: 100%; margin-top: 6px; padding: 10px; box-sizing: border-box; border: 1px solid #dadce0; border-radius: 4px; }
      button { background-color: #1a73e8; color: white; border: none; padding: 10px 18px; margin-top: 16px; font-weight: bold; cursor: pointer; border-radius: 4px; }
      button:hover { background-color: #1557b0; }
      #status { margin-top: 16px; font-weight: bold; }
    </style>
  </head>
  <body>
    <h2>Support Quick Portal</h2>
    <div class="badge">
      Active Google Workspace Studio triggers in Firestore (<code>studio_triggers</code>):
      <strong id="trigger-count">${activeCount}</strong>
    </div>
    <p>Submit a support ticket below to fire your Google Workspace Studio flow.</p>

    <label for="title">Ticket Title</label>
    <input type="text" id="title" placeholder="e.g. Database failure">

    <label for="description">Issue Description</label>
    <textarea id="description" rows="4" placeholder="Describe the issue..."></textarea>

    <label class="checkbox-label" for="simulateFailure">
      <input type="checkbox" id="simulateFailure">
      Simulate transient failure (enqueue in Cloud Tasks and retry notifyUri)
    </label>

    <button onclick="submitTicket()">Submit Ticket</button>
    <div id="status"></div>

    <script>
      async function submitTicket() {
        const title = document.getElementById('title').value.trim();
        const description = document.getElementById('description').value.trim();
        const simulateFailure = document.getElementById('simulateFailure').checked;
        const statusEl = document.getElementById('status');

        if (!title || !description) {
          statusEl.style.color = '#d93025';
          statusEl.textContent = 'Please fill out both fields.';
          return;
        }

        statusEl.style.color = '#188038';
        statusEl.textContent = 'Submitting ticket...';

        try {
          const res = await fetch('/api/submit-ticket', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ title, description, simulateFailure }),
          });
          const data = await res.json();
          if (data.ok) {
            if (data.tasksQueued > 0) {
              statusEl.textContent =
                'Ticket saved! Initial notifyUri call did not succeed; queued ' +
                data.tasksQueued +
                ' retry task(s) in Cloud Tasks.';
            } else {
              statusEl.textContent =
                'Ticket saved to Firestore! Fired ' +
                data.triggersFired +
                ' Google Workspace Studio trigger(s).';
            }
          } else {
            statusEl.style.color = '#d93025';
            statusEl.textContent = 'Error: ' + (data.error || 'Unknown error');
          }
        } catch (err) {
          statusEl.style.color = '#d93025';
          statusEl.textContent = 'Request failed: ' + err.message;
        }
      }
    </script>
  </body>
</html>`);
}

// Portal Form Submission -> Call notifyUri directly, or enqueue Cloud Task on failure
export async function submitTicket(req, res) {
  const title = req.body?.title || 'Untitled Ticket';
  const description = req.body?.description || '';
  const simulateFailure = Boolean(req.body?.simulateFailure);
  const results = [];
  let triggersFired = 0;
  let tasksQueued = 0;

  try {
    const snapshot = await triggersCollection.get();
    for (const doc of snapshot.docs) {
      const trigger = doc.data();
      if (!trigger.triggerId || !trigger.userOAuthToken) continue;

      const requestId =
        'req_' + Date.now() + '_' + Math.floor(Math.random() * 1000);

      let outcome = { ok: false, status: 503 };
      if (!simulateFailure) {
        try {
          outcome = await fireTriggerDirect(trigger, {
            title,
            description,
            requestId,
          });
        } catch (fetchErr) {
          console.error(
            `Direct trigger call failed for ${trigger.triggerId}:`,
            fetchErr.message
          );
          outcome = { ok: false, status: 0, error: fetchErr.message };
        }
      }

      if (outcome.ok) {
        triggersFired++;
        results.push({
          triggerId: trigger.triggerId,
          status: outcome.status,
          queuedRetry: false,
        });
      } else {
        const taskName = await enqueueTriggerRetry(req, {
          triggerId: trigger.triggerId,
          title,
          description,
          requestId,
          simulateFailure,
        });
        tasksQueued++;
        results.push({
          triggerId: trigger.triggerId,
          status: outcome.status,
          queuedRetry: true,
          taskName,
        });
      }
    }

    await ticketsCollection.add({
      title,
      description,
      triggersFired,
      tasksQueued,
      createdAt: new Date().toISOString(),
    });

    res.status(200).json({
      ok: true,
      triggersFired,
      tasksQueued,
      results,
    });
  } catch (err) {
    console.error('Error in /api/submit-ticket:', err.message);
    res.status(500).json({ ok: false, error: err.message });
  }
}
EOF

index.js — Express 서버 진입점

index.js를 만들어 포털 경로, Cloud Tasks 재시도 작업자 엔드포인트, Google Workspace Studio 웹훅 엔드포인트를 PORT (Cloud Run의 기본값 8080)에서 수신 대기하는 Express HTTP 서버에 연결합니다.

cat << 'EOF' > index.js
import express from 'express';
import { onConfigTrigger, onManageTrigger } from './starter.js';
import { onConfigUrgency, onExecuteUrgency } from './action.js';
import { renderPortal, submitTicket } from './portal.js';
import { handleFireTriggerTask } from './tasks.js';

const app = express();
app.use(express.json());

// Support Quick Portal routes
app.get('/', renderPortal);
app.post('/api/submit-ticket', submitTicket);

// Cloud Tasks retry worker route
app.post('/api/tasks/fire-trigger', handleFireTriggerTask);

// Google Workspace Studio Custom Starter Step webhooks
app.post('/onConfigTrigger', onConfigTrigger);
app.post('/onManageTrigger', onManageTrigger);

// Google Workspace Studio Custom Action Step webhooks
app.post('/onConfigUrgency', onConfigUrgency);
app.post('/onExecuteUrgency', onExecuteUrgency);

const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
  console.log(`Support Extension listening on port ${PORT}`);
});
EOF

4. Cloud Run에 배포 및 부가기능 설치

이 단계에서는 Node.js 서비스를 Cloud Run에 배포하고, Google Workspace 부가기능 호출자 및 Firestore, Cloud Tasks, 에이전트 플랫폼 런타임 액세스에 대한 IAM 권한을 부여하고, deployment.json 매니페스트를 생성하고, 부가기능 배포를 설치합니다.

  1. 프로젝트, Firestore 컬렉션, Cloud Tasks 태스크 큐 환경 변수를 사용하여 support-extension 서비스를 Cloud Run에 배포합니다.
    gcloud run deploy support-extension \
      --source . \
      --region us-central1 \
      --allow-unauthenticated \
      --set-env-vars="GOOGLE_CLOUD_PROJECT=$(gcloud config get-value project),FIRESTORE_TRIGGERS_COLLECTION=studio_triggers,FIRESTORE_TICKETS_COLLECTION=support_tickets,CLOUD_TASKS_QUEUE=studio-trigger-retries,CLOUD_TASKS_LOCATION=us-central1" \
      --quiet
    
  2. Google Workspace Studio에서 웹훅 엔드포인트를 호출할 수 있도록 Google Workspace 부가기능 서비스 계정에 Cloud Run 서비스에 대한 roles/run.invoker를 부여합니다.
    ADDON_SA=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
    gcloud run services add-iam-policy-binding support-extension \
      --region=us-central1 \
      --member="serviceAccount:${ADDON_SA}" \
      --role="roles/run.invoker" \
      --quiet
    
  3. studio_triggers 및 support_tickets Firestore 컬렉션 읽기 및 쓰기를 위한 roles/datastore.user, studio-trigger-retries에 재시도 작업을 대기열에 추가하기 위한 roles/cloudtasks.enqueuer, Gemini Enterprise Agent Platform에서 Gemini를 호출하기 위한 roles/aiplatform.user를 Cloud Run 런타임 서비스 계정에 부여합니다.
    PROJECT_ID=$(gcloud config get-value project)
    PROJECT_NUMBER=$(gcloud projects describe "${PROJECT_ID}" --format="value(projectNumber)")
    RUNTIME_SA=$(gcloud run services describe support-extension --region=us-central1 --format="value(spec.template.spec.serviceAccountName)")
    RUNTIME_SA=${RUNTIME_SA:-"${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"}
    
    gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
      --member="serviceAccount:${RUNTIME_SA}" \
      --role="roles/datastore.user" \
      --condition=None \
      --quiet
    
    gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
      --member="serviceAccount:${RUNTIME_SA}" \
      --role="roles/cloudtasks.enqueuer" \
      --condition=None \
      --quiet
    
    gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
      --member="serviceAccount:${RUNTIME_SA}" \
      --role="roles/aiplatform.user" \
      --condition=None \
      --quiet
    
  4. 배포된 Cloud Run URL을 검색하고 supportTicketTrigger (workflowTrigger) 및 urgencyDetectorStep (workflowAction) 요소를 정의하는 deployment.json 매니페스트를 생성합니다.
    SERVICE_URL=$(gcloud run services describe support-extension --region=us-central1 --format="value(status.url)")
    
    cat << EOF > deployment.json
    {
      "oauthScopes": [
        "https://www.googleapis.com/auth/workspace.studio.trigger",
        "https://www.googleapis.com/auth/userinfo.email"
      ],
      "addOns": {
        "common": {
          "name": "Support Extension",
          "logoUrl": "https://www.gstatic.com/images/branding/productlogos/buganizer/v1/192px.svg"
        },
        "studio": {
          "flows": {
            "workflowElements": [
              {
                "id": "supportTicketTrigger",
                "state": "ACTIVE",
                "name": "New Support Ticket",
                "description": "Fires when someone submits a ticket in the Support Quick Portal.",
                "workflowTrigger": {
                  "inputs": [],
                  "outputs": [
                    {
                      "id": "ticketTitle",
                      "description": "Ticket Title",
                      "cardinality": "SINGLE",
                      "dataType": { "basicType": "STRING" }
                    },
                    {
                      "id": "ticketDescription",
                      "description": "Ticket Description",
                      "cardinality": "SINGLE",
                      "dataType": { "basicType": "STRING" }
                    }
                  ],
                  "onConfigFunction": "${SERVICE_URL}/onConfigTrigger",
                  "onManageFunction": "${SERVICE_URL}/onManageTrigger"
                }
              },
              {
                "id": "urgencyDetectorStep",
                "state": "ACTIVE",
                "name": "Detect Urgency",
                "description": "Evaluates the ticket description to detect High or Normal urgency.",
                "workflowAction": {
                  "inputs": [
                    {
                      "id": "ticketDescription",
                      "description": "Description to scan",
                      "cardinality": "SINGLE",
                      "dataType": { "basicType": "STRING" }
                    }
                  ],
                  "outputs": [
                    {
                      "id": "urgencyLevel",
                      "description": "Detected Urgency (High / Normal)",
                      "cardinality": "SINGLE",
                      "dataType": { "basicType": "STRING" }
                    }
                  ],
                  "onConfigFunction": "${SERVICE_URL}/onConfigUrgency",
                  "onExecuteFunction": "${SERVICE_URL}/onExecuteUrgency"
                }
              }
            ]
          }
        },
        "httpOptions": {
          "authorizationHeader": "SYSTEM_ID_TOKEN"
        }
      }
    }
    EOF
    
  5. 계정의 Google Workspace 부가기능 배포를 만들거나 대체하고 설치한 후 지원 빠른 포털 URL을 출력합니다.
    gcloud workspace-add-ons deployments create support-extension --deployment-file=deployment.json || \
      gcloud workspace-add-ons deployments replace support-extension --deployment-file=deployment.json
    gcloud workspace-add-ons deployments install support-extension
    echo "Support Quick Portal URL: ${SERVICE_URL}"
    

5. 커스텀 단계 및 Cloud Tasks 재시도 테스트

  1. Google Workspace Studio를 열고 (이미 열려 있는 경우 브라우저 탭을 새로고침) 새 플로우를 클릭합니다.
  2. 스타터 선택 > 지원 확장 프로그램 > 새 지원 티켓 을 클릭합니다.
  3. 단계 추가 (+) > 지원 확장 프로그램 > 긴급도 감지 를 클릭합니다. 소스 설명 필드에서 + 변수를 클릭하고 티켓 설명을 선택합니다.
  4. 단계 추가 (+)를 클릭하고 메시지에 티켓 제목, 긴급도 수준, 티켓 설명 변수가 포함된 채팅 > 채팅에서 알림 단계를 추가합니다.
  5. 오른쪽 상단에서 플로우를 사용 설정합니다. Google Workspace Studio는 Cloud Run에서 /onManageTrigger 엔드포인트를 즉시 호출하여 활성 트리거 구독 (triggerId, notifyUri, userOAuthToken 포함)을 Firestore의 studio_triggers 컬렉션에 씁니다.
  6. 새 탭에서 지원 빠른 포털 URL (https://support-extension-...run.app)을 열고 포털에 Firestore의 활성 트리거가 1개 표시되는지 확인한 후 두 개의 티켓을 제출하여 Gemini 3.8 Flash (gemini-3.8-flash) 분류를 테스트합니다.
    • 영향이 큰 티켓 (티켓 제목: London office lockout, 문제 설명: All 500 employees in the London office are locked out of their workstations 20 minutes before the board presentation.): urgent 또는 blocker와 같은 하드코딩된 키워드가 없어도 gemini-3.8-flash는 이 티켓을 High 긴급도로 분류합니다.
    • 일상적인 티켓 (티켓 제목: Conference room adapters, 문제 설명: Could we order two extra HDMI adapters for the third-floor conference room next month?): gemini-3.8-flash는 이 티켓을 Normal 긴급도로 분류합니다.
  7. Google Chat 및 Google Workspace Studio 활동 탭에서 Urgency Level: High 및 Urgency Level: Normal으로 두 티켓 모두에 대해 플로우가 실행되었는지 확인합니다.
  8. notifyUri가 실패할 때 Cloud Tasks 재시도 전송 을 테스트합니다.
    1. 지원 빠른 포털에서 일시적인 실패 시뮬레이션 (Cloud Tasks에 대기열에 추가하고 notifyUri 재시도)을 선택합니다.
    2. 티켓 제목에 Checkout service outage을 입력하고 문제 설명에 Production payment gateway is timing out for all customers을 입력한 후 티켓 제출을 클릭합니다.
    3. 포털이 Ticket saved! Initial notifyUri call did not succeed; queued 1 retry task(s) in Cloud Tasks.로 즉시 응답하는 것을 확인합니다.
    4. 백그라운드에서 Cloud Tasks가 POST /api/tasks/fire-trigger를 실행합니다. 첫 번째 작업 시도 (retryCount=0)는 503 실패를 시뮬레이션하여 Cloud Tasks가 10s (--min-backoff=10s) 동안 백오프하고 재시도 (retryCount=1)하도록 합니다. 그러면 notifyUri가 호출되고 성공합니다. 약 10~15초 후에 Google Chat 알림이 도착하는지 확인합니다.

6. 삭제

Google Cloud 계정에 지속적인 요금이 청구되지 않도록 Cloud Shell에서 배포된 리소스를 삭제합니다.

gcloud workspace-add-ons deployments uninstall support-extension
gcloud workspace-add-ons deployments delete support-extension --quiet
gcloud run services delete support-extension --region=us-central1 --quiet
gcloud tasks queues delete studio-trigger-retries --location=us-central1 --quiet
gcloud firestore databases delete --database="(default)" --quiet

7. 수고하셨습니다

Gemini Enterprise Agent Platform 에서 Firestore, Cloud Tasks, Cloud Run, Gemini 3.8 Flash 를 사용하여 Google Workspace Studio 용 Node.js HTTP 확장 프로그램을 빌드, 배포, 테스트했습니다.

학습한 내용

  • workflowTrigger 및 workflowAction 요소로 Google Workspace Studio HTTP 부가기능 매니페스트 (deployment.json) 구성
  • studio_triggers Firestore 컬렉션에 Google Workspace Studio workflowTrigger 구독을 유지하고 support_tickets에 포털 제출을 로깅하여 트리거가 Cloud Run 확장 및 재배포에서 유지되도록 함
  • Cloud Tasks (studio-trigger-retries)로 notifyUri (triggers.fire) 전송을 보장하여 일시적인 HTTP 실패가 Firestore의 최신 userOAuthToken을 사용하여 지수 백오프로 자동으로 재시도되도록 함
  • 복원력이 있는 키워드 일치 대체 기능을 사용하여 Gemini Enterprise Agent Platform에서 Gemini 3.8 Flash (gemini-3.8-flash) 구조화된 출력으로 지원 티켓을 분류하는 workflowAction 단계 빌드

다음 단계