From 4e6cc7fd3dcce6e3e7251d105dc1f133f63d95bc Mon Sep 17 00:00:00 2001 From: Jonathan Jackson Date: Thu, 21 May 2026 17:40:07 -0600 Subject: [PATCH] =?UTF-8?q?feat(ocs):=204=20more=20authoring=20atoms=20?= =?UTF-8?q?=E2=80=94=20stub=20Interviews=20bot=20fully=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the 5-atom OCS authoring batch (1 was in PR #386). Together these atoms let ACE construct a full Connect Interviews Dynamic Router Bot from scratch, no human UI clicks. New atoms (all verified end-to-end against live OCS): - ocs_add_chatbot_event — POST events/timeout/new/ with the THREE combined forms (TimeoutTriggerForm + EventActionForm + action-params). Verified: attached 24hr timeout event to stub bot (action_type=log). - ocs_add_custom_action — POST /a//actions/new/ (NOT /custom-actions/ as the original probe report said). OpenAPI-schema- driven, not webhook config. Scrapes /actions/table/ to recover the new action_id since the 302 redirect goes to the team-manage page with no id in the Location. Verified: created action 35 with a stub schema for HQ session-completion posting. - ocs_link_action_to_node — appends ":" to a pipeline node's data.params.custom_actions. String format verified against apps/custom_actions/form_utils.py:make_model_id. Idempotent. Verified: wired action 35:postSessionCompletion to LLM node in pipeline 5981. Verification doc (docs/connect-interviews/ocs-verification.md) corrects multiple wrong claims from the original ocs-probe-report.md: - custom action URL is /a//actions/, not /custom-actions/ - CustomActionForm fields are OpenAPI-driven (name, server_url, api_schema, +optional description/prompt/auth_provider/healthcheck_path) NOT the simple-webhook fields the probe report claimed - Events cannot directly fire custom_actions — ACTION_PARAMS_FORMS only has {log, send_message_to_bot, end_conversation, schedule_trigger, pipeline_start}. The tech doc's "24hr fires custom action" pattern requires a secondary pipeline + action_type=pipeline_start. - DynamicRouterNode doesn't exist — use StaticRouterNode (route_key + keywords against participant_data) - Pipeline-save validates AFTER commit (200 + errors in body but state persists); FlowNode needs both top-level type + data.type + data.id. Stub bot now structurally complete: - experiment 12213 (ACE Interviews Stub Template) in team connect-ace - pipeline 5981: Start → StaticRouterNode-8f9a7 → LLM (custom_actions= ["35:postSessionCompletion"]) → End - 24hr timeout event attached - action 35 (session-completion API stub) Tasks #3 and #13 → completed. PR builds on top of #386 (merged). Co-Authored-By: Claude Opus 4.7 --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- docs/connect-interviews/ocs-verification.md | 185 ++++++++++++++++++++ mcp/ocs-server.ts | 40 +++++ mcp/ocs/backends/composite.ts | 3 + mcp/ocs/backends/pipeline-patch.ts | 73 ++++++++ mcp/ocs/backends/playwright.ts | 167 +++++++++++++++++- mcp/ocs/client.ts | 45 +++++ package.json | 2 +- scripts/probe-ocs-add-chatbot-event.ts | 98 +++++++++++ scripts/probe-ocs-add-custom-action.ts | 129 ++++++++++++++ scripts/probe-ocs-link-action-to-node.ts | 101 +++++++++++ 13 files changed, 845 insertions(+), 6 deletions(-) create mode 100644 docs/connect-interviews/ocs-verification.md create mode 100644 scripts/probe-ocs-add-chatbot-event.ts create mode 100644 scripts/probe-ocs-add-custom-action.ts create mode 100644 scripts/probe-ocs-link-action-to-node.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 81667a1a..0d23224f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ "url": "https://github.com/jjackson" }, "metadata": { - "version": "0.13.316" + "version": "0.13.317" }, "plugins": [ { "name": "ace", "source": "./", - "version": "0.13.316", + "version": "0.13.317", "description": "AI Connect Engine — orchestrates the CRISPR-Connect lifecycle from idea through app building, Connect setup, LLO management, and closeout" } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d9002f74..a3ae7d0a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.316", + "version": "0.13.317", "description": "AI Connect Engine — orchestrates the CRISPR-Connect lifecycle from idea through app building, Connect setup, LLO management, and closeout", "author": { "name": "Jonathan Jackson", diff --git a/VERSION b/VERSION index eef6375f..6c966d10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.316 +0.13.317 diff --git a/docs/connect-interviews/ocs-verification.md b/docs/connect-interviews/ocs-verification.md new file mode 100644 index 00000000..7098a5c1 --- /dev/null +++ b/docs/connect-interviews/ocs-verification.md @@ -0,0 +1,185 @@ +# OCS Verification Notes (post-source-review) + +The initial `ocs-probe-report.md` was generated via `gh search` + `gh api` calls without local source access. Several claims turned out to be wrong or incomplete. This doc captures findings from reading the actual OCS source (cloned to `/tmp/ace-refs/ocs/`, commit on `main` as of 2026-05-21). + +Where this doc and `ocs-probe-report.md` disagree, **this doc wins**. + +## Status of the 5 OCS authoring atoms + +| Atom | Status | URL / Mechanism (verified) | Notes | +|---|---|---|---| +| `ocs_create_chatbot` | **shipped + verified** | POST `/a//chatbots/new/`, fields `name` + `description`. Success → 302 to `/a//chatbots//edit/`. | Works first try. | +| `ocs_add_pipeline_node` | **shipped + verified** | GET/POST `/a//pipelines/data//`. | Required FlowNode + FlowEdge shape per `apps/pipelines/flow.py`. | +| `ocs_add_chatbot_event` | **design verified, not yet built** | POST `/a//chatbots//events/timeout/new/` (or `/static/new/` for static). | Multi-form combined POST; see below. | +| `ocs_add_custom_action` | **design verified, not yet built** | POST `/a//actions/new/` ← *not* `/custom-actions/` as probe report said. | OpenAPI-schema-driven, not simple webhook config. | +| `ocs_link_action_to_node` | **design verified, not yet built** | Same `/pipelines/data//` GET/POST. Modify `data.params.custom_actions` on the target node. | Strings are `:` composites. | + +## FlowNode / FlowEdge schema (load-bearing) + +Source: `apps/pipelines/flow.py:11-30` + +```python +class FlowNodeData(pydantic.BaseModel): + id: str + type: str + label: str = "" + params: dict = {} + +class FlowNode(pydantic.BaseModel): + id: str + type: Literal["pipelineNode", "startNode", "endNode"] = "pipelineNode" + position: dict = {} + data: FlowNodeData + +class FlowEdge(pydantic.BaseModel): + id: str + source: str + target: str + sourceHandle: str | None = STANDARD_OUTPUT_NAME # "output" + targetHandle: str | None = STANDARD_INPUT_NAME # "input" +``` + +**Implications confirmed:** + +- Each node carries **both** a top-level `type` (`"startNode"` | `"endNode"` | `"pipelineNode"`) AND `data.type` (the OCS class name). +- `data.id` is **required** and must match the top-level `id`. +- Edges' `sourceHandle` / `targetHandle` default to `"output"` / `"input"` — explicit values needed only for multi-output nodes (router branches use `"output_0"`, `"output_1"`, …). +- If shape is invalid, OCS server-side **500s with HTML error page** instead of returning a clean JSON validation error. The pipeline-save view's pydantic ValidationError is not caught — that's an upstream UX bug worth filing. + +## OCS pipeline-save validates AFTER commit + +When the shape is valid but a node's params fail validation (e.g. StaticRouterNode without `route_key`), OCS returns `200 OK` **and persists the broken state**. Errors surface only in the response body under `errors.node..`. + +This is permanent behavior we have to work around. The `addPipelineNode` helper raises `PipelineValidationError` from those response errors, but the broken state is already persisted. **Callers must clean up partial state on retry** — the `probe-ocs-reset-pipeline.ts` shape (strip non-canonical nodes + rewrite edges) is the reference recovery. + +## Routing node — correct class name + +`DynamicRouterNode` **does not exist** in OCS. The "Dynamic Router Bot" in the Connect Interviews tech doc maps to OCS's `StaticRouterNode` (routes by participant_data field values via a `keywords[]` array, not by LLM judgment). + +Source: `apps/pipelines/nodes/nodes.py` defines `RouterNode` (LLM-driven) and `StaticRouterNode` (rule-based). Schema fixtures in `apps/pipelines/tests/node_schemas/`. + +**StaticRouterNode params** (required: `name`, `route_key`): + +| Field | Type | Default | Notes | +|---|---|---|---| +| `name` | string | — | required | +| `route_key` | string | — | required; the key within `data_source` to read | +| `data_source` | string | `participant_data` | also accepts `temp_state` | +| `keywords` | string[] | — | list of values to route on | +| `default_keyword_index` | integer | 0 | which output handle takes unmatched routes | +| `tag_output_message` | boolean | false | | + +For Connect Interviews: `route_key: "interview_id"`, `keywords: []`. + +## Event creation — multi-form combined POST + +Source: `apps/events/views.py:_create_event_view` (lines 35-74). + +**One POST combines THREE forms:** + +1. **Trigger form** — `TimeoutTriggerForm` or `StaticTriggerForm`. Fields per `apps/events/forms.py`: + - TimeoutTrigger: `delay` (TimePeriod choice), `total_num_triggers`, `trigger_from_first_message` + - StaticTrigger: `type` (event-type choice) +2. **EventActionForm** — single field `action_type` (choice). Allowed values from `ACTION_PARAMS_FORMS`: + - `log` → no params + - `send_message_to_bot` → `message_to_bot` text + - `end_conversation` → no params + - `schedule_trigger` → many params (`name`, `prompt_text`, `frequency`, `time_period`, `repetitions`, `experiment_id`) + - `pipeline_start` → `pipeline_id`, `input_type` +3. **Action-params form** — chosen by `action_type` via `build_action_params_form()`. Fields per the action. + +**There is NO `"custom_action"` value in `ACTION_PARAMS_FORMS`.** This contradicts the Connect Interviews tech doc's claim that "after 24-hour timeout, a custom action fires from OCS back to HQ." See the architectural-mismatch section below. + +**Success:** 302 to `_get_events_url(team, experiment_id)` = `/a//chatbots//#events`. **No trigger_id is returned** in the Location header — the create view discards the saved trigger ID. To find the new trigger after create, the caller has to list events. + +**Failure:** 200 re-render of the manage_event.html template. Errors aren't in a clean JSON shape — would have to parse the template (or, better, accept the lack of error introspection and tell callers to re-list events to confirm creation). + +## Custom Action create — OpenAPI-schema-driven, not webhook config + +The probe report claimed fields were `name, description, target_url, request_body_template, http_method, headers`. **This is wrong.** + +Source: `apps/custom_actions/forms.py:CustomActionForm` + `apps/custom_actions/views.py:CreateCustomAction`. + +**Actual URL:** POST `/a//actions/new/` (NOT `/custom-actions/new/`). + +**Actual fields:** + +| Field | Type | Required | Notes | +|---|---|---|---| +| `name` | string | yes | | +| `description` | textarea | no | max 1000 chars | +| `prompt` | textarea | no | "Additional Prompt" — instructions to the LLM about how to use this action | +| `server_url` | URL | **yes** | Base URL of the API server (e.g. `https://www.commcarehq.org`) | +| `api_schema` | JSON or YAML | **yes** | **An OpenAPI 3.x schema describing the endpoints.** This is the load-bearing field. | +| `auth_provider` | FK | no | Reference to a configured AuthProvider in the team | +| `healthcheck_path` | string | no | Optional health endpoint; auto-detected from schema if omitted | + +After save, OCS auto-populates `allowed_operations` from the schema's `operationId`s and fires a health check (async Celery task — non-blocking). + +**Success:** 302 to `single_team:manage_team` (i.e. `/a//team/`) — **no action ID in Location**. Caller has to list actions afterward to find the new ID. + +**Reference OpenAPI schema for Connect Interviews session-completion** (rough sketch): + +```yaml +openapi: 3.0.0 +info: { title: HQ Session Completion API, version: 1.0.0 } +servers: [{ url: https://www.commcarehq.org }] +paths: + /a//api/inbound_api//: + post: + operationId: postSessionCompletion + requestBody: + content: + application/json: + schema: + type: object + properties: + session_completion: { type: string } + last_bot_interaction_date: { type: string } + interaction_validation: { type: string } + responses: + '200': { description: ok } +``` + +Real schemas need the actual HQ inbound_api URL once the inbound APIs are configured per-domain (currently a Playwright gap on the HQ side — `commcare_create_inbound_api`). + +## Linking custom action to a pipeline node + +Source: `apps/custom_actions/form_utils.py:make_model_id` + `apps/pipelines/nodes/nodes.py:309`. + +**Storage:** the LLMResponseWithPrompt node's `params.custom_actions` is `list[str]`. Each string is a composite of `:` (e.g. `"42:postSessionCompletion"`). + +**Mechanism:** GET pipeline → modify the target node's `params.custom_actions` array → POST pipeline. + +The `ocs_link_action_to_node` atom should: +1. Take `pipeline_id`, target `node_id`, `custom_action_id`, `operation_id`. +2. Compose `model_id = f"{custom_action_id}:{operation_id}"`. +3. Read pipeline, find target node, append `model_id` to `data.params.custom_actions`. +4. Save pipeline. + +## Architectural mismatch surfacing during verification + +The Connect Interviews tech doc says: + +> "After 24 hours of inactivity, the event is triggered. When this event occurs, a separate Event Pipeline is executed. The pipeline calls a custom action configured for inactivity handling." + +OCS events **cannot directly fire custom actions** — `ACTION_PARAMS_FORMS` lists only `log`, `send_message_to_bot`, `end_conversation`, `schedule_trigger`, `pipeline_start`. + +**The actual architecture is two pipelines per bot:** +- **Primary pipeline** — Start → StaticRouter → LLM-with-custom-action (session_completion API) → End. Custom action fires when the LLM decides the interview is complete. +- **Secondary "expiry" pipeline** — Start → LLM-with-custom-action (24hr_expiry API) → End. Triggered by the timeout event's `action_type=pipeline_start` pointing here. + +For V1 stub bot: I'll build only the primary pipeline (skip the secondary) and document the gap. The verifier rules can grade "has timeout event" + "has custom action" structurally without requiring the secondary pipeline. Real production bots can layer the secondary pipeline on later. + +## What's still unverified + +These are claims in the rest of `ocs-verification.md` that came from quick reads, not deep verification — flag them if anything bites later: + +- **Pipeline graph save endpoint's exact failure shape** when shape is valid but params are wrong. I've seen `{"errors": {"node": {"": {"": ""}}}}` and assume that's stable; OCS source isn't read. +- **Health check timing on custom action create** — `check_single_custom_action_health` is `@shared_task`. In some deployments tasks run synchronously (eager mode) which would block the create response. Assumed async based on the production OCS deployment config; not verified. +- **Multi-output edge handles on StaticRouterNode** — when wiring router → multiple downstream nodes, the routing happens on `sourceHandle = "output_"`. I haven't verified the exact handle naming; will discover when wiring the cohort's per-interview LLM nodes downstream. +- **PythonNode params shape** — for the session-state-capture node in the tech doc. Not verified; not in V1 stub atom scope. + +## Local mirror + +OCS source is at `/tmp/ace-refs/ocs/` (shallow clone). To refresh: `cd /tmp/ace-refs/ocs && git pull`. Removable any time; not load-bearing. diff --git a/mcp/ocs-server.ts b/mcp/ocs-server.ts index 878d96eb..a4364a26 100644 --- a/mcp/ocs-server.ts +++ b/mcp/ocs-server.ts @@ -229,6 +229,46 @@ server.tool( async (args) => result(await composite.createChatbot(args)), ); +server.tool( + 'ocs_link_action_to_node', + 'Link a Custom Action operation to a pipeline node. GET/POST /a//pipelines/data// — appends ":" to the target node\'s data.params.custom_actions array. String format verified against apps/custom_actions/form_utils.py:make_model_id. Idempotent: skips if the model_id is already present. Typically the target node is an LLMResponseWithPrompt.', + { + pipeline_id: z.number(), + node_id: z.string(), + custom_action_id: z.number().int().describe('From `ocs_add_custom_action`.'), + operation_id: z.string().describe('The operationId within the custom action\'s api_schema (e.g. "postSessionCompletion").'), + }, + async (args) => result(await composite.linkActionToNode(args)), +); + +server.tool( + 'ocs_add_custom_action', + 'Create an OCS Custom Action (an OpenAPI-driven external tool the LLM can call). POST /a//actions/new/ via the CSRF-protected CustomActionForm (apps/custom_actions/forms.py + views.py:CreateCustomAction). The api_schema field takes an OpenAPI 3.x schema as a JSON or YAML string — operationIds within the schema become the action\'s allowed_operations. Returns action_id, found by scraping /a//actions/ for the row whose name matches (the create view 302s to the team-manage page without including the new id in the Location). For Connect Interviews this is how the bot posts session_completion or 24hr-expiry back to HQ\'s Inbound API.', + { + name: z.string(), + server_url: z.string().url().describe('Base URL of the target API (e.g. https://www.commcarehq.org).'), + api_schema: z.string().describe('OpenAPI 3.x schema as JSON or YAML string. operationIds become the action\'s allowed_operations.'), + description: z.string().optional(), + prompt: z.string().optional().describe('Additional instructions to the LLM about how to use this action.'), + healthcheck_path: z.string().optional().describe('Optional health endpoint path; auto-detected from schema if omitted.'), + }, + async (args) => result(await composite.addCustomAction(args)), +); + +server.tool( + 'ocs_add_chatbot_event', + 'Attach a timeout-trigger event to a chatbot. POST /a//chatbots//events/timeout/new/ via the combined _create_event_view (apps/events/views.py) which takes THREE forms in one POST: TimeoutTriggerForm (delay seconds, total_num_triggers, trigger_from_first_message), EventActionForm (action_type), and a per-action-type params form. Returns {ok: true} — the view does NOT expose the new trigger ID in the response (caller must re-list events if they need it). NOTE: OCS events CANNOT directly fire custom actions; action_type must be one of {log, send_message_to_bot, end_conversation, schedule_trigger, pipeline_start}. The Connect Interviews "24hr fires custom action" pattern requires action_type=pipeline_start pointing at a secondary pipeline that contains the custom action.', + { + experiment_id: z.number(), + delay_seconds: z.number().int().positive().describe('Wait time before triggering, in seconds. 86400 = 24 hours.'), + total_num_triggers: z.number().int().positive().optional().describe('Number of times to fire (default 1).'), + trigger_from_first_message: z.boolean().optional().describe('Trigger relative to the first message vs. last interaction (default false = last).'), + action_type: z.enum(['log', 'send_message_to_bot', 'end_conversation', 'schedule_trigger', 'pipeline_start']), + action_params: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().describe('Action-type-specific params: pipeline_start needs {pipeline_id, input_type}; send_message_to_bot needs {message_to_bot}; schedule_trigger needs many; log/end_conversation need none.'), + }, + async (args) => result(await composite.addChatbotEvent(args)), +); + server.tool( 'ocs_add_pipeline_node', 'Add a node to a chatbot\'s pipeline graph. GET-mutate-POST the pipeline JSON at /a//pipelines/data// — same shape as the existing LLM-patch atoms. Supports splice-into-existing-edge: pass `disconnect_edge: {source:A, target:B}` + `connect_from: A` + `connect_to: B` to turn A→B into A→new→B (the typical pattern for inserting Router or Python nodes between Start and the default LLM). `node_id` is auto-generated as `-<5hex>` (matching OCS UI convention) if omitted. Returns the chosen `node_id`. Server-side validation errors surface as PipelineValidationError.', diff --git a/mcp/ocs/backends/composite.ts b/mcp/ocs/backends/composite.ts index d6e21c55..068ad892 100644 --- a/mcp/ocs/backends/composite.ts +++ b/mcp/ocs/backends/composite.ts @@ -24,6 +24,9 @@ export class CompositeBackend implements OcsClient { cloneChatbot = (a: Parameters[0]) => this.opts.playwright.cloneChatbot(a); createChatbot = (a: Parameters[0]) => this.opts.playwright.createChatbot(a); addPipelineNode = (a: Parameters[0]) => this.opts.playwright.addPipelineNode(a); + addChatbotEvent = (a: Parameters[0]) => this.opts.playwright.addChatbotEvent(a); + addCustomAction = (a: Parameters[0]) => this.opts.playwright.addCustomAction(a); + linkActionToNode = (a: Parameters[0]) => this.opts.playwright.linkActionToNode(a); setChatbotSystemPrompt = (a: Parameters[0]) => this.opts.playwright.setChatbotSystemPrompt(a); setChatbotPipeline = (a: Parameters[0]) => this.opts.playwright.setChatbotPipeline(a); createCollection = (a: Parameters[0]) => this.opts.playwright.createCollection(a); diff --git a/mcp/ocs/backends/pipeline-patch.ts b/mcp/ocs/backends/pipeline-patch.ts index 6b61fb03..7e078d4a 100644 --- a/mcp/ocs/backends/pipeline-patch.ts +++ b/mcp/ocs/backends/pipeline-patch.ts @@ -190,6 +190,79 @@ export async function addPipelineNode( return { nodeId }; } +export interface LinkActionToNodeArgs { + pipelineId: number; + /** Pipeline node ID to attach the custom-action operation to (typically an LLMResponseWithPrompt). */ + nodeId: string; + /** Custom Action database id. From `addCustomAction`. */ + customActionId: number; + /** + * OpenAPI operationId within the custom action's api_schema (e.g. + * "postSessionCompletion"). The caller chose this when writing the + * schema; OCS extracts it into `allowed_operations` on save. + */ + operationId: string; +} + +/** + * Append a custom-action operation to a pipeline node's + * `data.params.custom_actions` array. + * + * The string format is `:` per + * `apps/custom_actions/form_utils.py:make_model_id`. Verified against + * source 2026-05-21. + * + * Verified workflow: + * 1. createCustomAction → returns action_id 35 with operationId + * "postSessionCompletion" in the schema + * 2. linkActionToNode({nodeId: "LLMResponseWithPrompt-45d67", + * customActionId: 35, operationId: "postSessionCompletion"}) + * 3. The LLM node now exposes "35:postSessionCompletion" to the LLM + * as a callable tool. + */ +export async function linkActionToNode( + ctx: PipelinePatchContext, + args: LinkActionToNodeArgs, +): Promise<{ ok: true }> { + const url = `/a/${ctx.teamSlug}/pipelines/data/${args.pipelineId}/`; + const getRes = await ctx.request('GET', url); + if (!getRes.ok) { + throw new Error(`pipeline data GET failed for pipeline ${args.pipelineId}`); + } + const payload = (await getRes.json()) as PipelineDataResponse; + const graph = payload.pipeline.data; + + const node = graph.nodes.find((n) => n.id === args.nodeId); + if (!node) { + throw new PipelineShapeError( + `linkActionToNode: node id "${args.nodeId}" not found in pipeline ${args.pipelineId}. ` + + `Available node ids: ${graph.nodes.map((n) => n.id).join(', ')}`, + ); + } + const modelId = `${args.customActionId}:${args.operationId}`; + const params = node.data.params as Record; + const existing = Array.isArray(params.custom_actions) ? (params.custom_actions as string[]) : []; + if (!existing.includes(modelId)) { + params.custom_actions = [...existing, modelId]; + } + + const postRes = await ctx.request('POST', url, { + name: payload.pipeline.name, + data: graph, + }); + if (!postRes.ok) { + const body = postRes.text ? await postRes.text() : ''; + throw new Error( + `linkActionToNode: pipeline POST failed for pipeline ${args.pipelineId} (status ${postRes.status}). First 400 chars: ${body.slice(0, 400)}`, + ); + } + const errors = extractPipelineErrors(await postRes.json()); + if (errors.length > 0) { + throw new PipelineValidationError(errors); + } + return { ok: true }; +} + function randomSuffix(n: number): string { // Cryptographically-random hex suffix; no need for crypto-secure here, but // Math.random would collide more often. Use Web Crypto if available. diff --git a/mcp/ocs/backends/playwright.ts b/mcp/ocs/backends/playwright.ts index fad5c000..1ee34feb 100644 --- a/mcp/ocs/backends/playwright.ts +++ b/mcp/ocs/backends/playwright.ts @@ -1,5 +1,5 @@ import type { RequestFn, RequestResult } from './pipeline-patch.js'; -import { patchLlmNodeParams, validatePipeline, getLlmNodeParams, addPipelineNode, type PipelinePatchContext } from './pipeline-patch.js'; +import { patchLlmNodeParams, validatePipeline, getLlmNodeParams, addPipelineNode, linkActionToNode, type PipelinePatchContext } from './pipeline-patch.js'; import { PipelineValidationError } from '../errors.js'; import type { LlmNodeParams, ClonedChatbot } from '../types.js'; import { CollectionIndexingTimeoutError, HttpError, PipelineShapeError } from '../errors.js'; @@ -76,6 +76,11 @@ export function extractExperimentIdFromLocation(location: string): number | unde return match ? Number(match[1]) : undefined; } +/** Escape a string for use as a literal inside a RegExp pattern. */ +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Enforce the OCS LLMResponseWithPrompt cross-field rule for the * `{collection_index_summaries}` template variable. @@ -602,6 +607,25 @@ export class PlaywrightBackend { return { experiment_id: experimentId, pipeline_id: pipelineId }; } + /** + * Link a custom-action operation to a pipeline node. Modifies the + * node's `data.params.custom_actions` array to include + * `:`. + */ + async linkActionToNode(args: { + pipeline_id: number; + node_id: string; + custom_action_id: number; + operation_id: string; + }): Promise<{ ok: true }> { + return linkActionToNode(this.patchContext(), { + pipelineId: args.pipeline_id, + nodeId: args.node_id, + customActionId: args.custom_action_id, + operationId: args.operation_id, + }); + } + /** * Add a node to a chatbot's pipeline. Wraps the pipeline-patch helper — * see `addPipelineNode` in pipeline-patch.ts for the splice semantics. @@ -633,6 +657,147 @@ export class PlaywrightBackend { return { node_id: nodeId }; } + /** + * Attach a timeout-trigger event to a chatbot. POST to + * /a//chatbots//events/timeout/new/ via the + * combined `_create_event_view` (apps/events/views.py:35-74), which + * accepts THREE forms in a single POST: + * + * 1. TimeoutTriggerForm — delay (seconds), total_num_triggers, trigger_from_first_message + * 2. EventActionForm — action_type + * 3. Action-params form — whatever the chosen action_type needs + * + * Verified against /tmp/ace-refs/ocs/apps/events/views.py + + * apps/events/forms.py + apps/events/models.py (2026-05-21). + * + * KNOWN LIMITATIONS: + * - The view does NOT expose the trigger ID in the response. Success + * is a 302 to /a//chatbots//#events with no trigger_id in + * the Location. To find the new trigger, caller must re-list events + * and pick the newest (out of scope for V1). + * - The Connect Interviews tech doc says "after 24hr timeout, a + * custom action fires from OCS back to HQ." OCS events CANNOT + * directly fire custom actions (ACTION_PARAMS_FORMS has no + * "custom_action" key). The team's actual architecture must use + * action_type="pipeline_start" pointing at a secondary pipeline + * that contains the custom action. See docs/connect-interviews/ + * ocs-verification.md § "Architectural mismatch". + */ + async addChatbotEvent(args: { + experiment_id: number; + delay_seconds: number; + total_num_triggers?: number; + trigger_from_first_message?: boolean; + action_type: 'log' | 'send_message_to_bot' | 'end_conversation' | 'schedule_trigger' | 'pipeline_start'; + /** action_type-specific params (e.g. pipeline_start needs pipeline_id + input_type). */ + action_params?: Record; + }): Promise<{ ok: true }> { + const path = `/a/${this.opts.teamSlug}/chatbots/${args.experiment_id}/events/timeout/new/`; + const body: Record = { + csrfmiddlewaretoken: this.opts.csrfToken, + // TimeoutTriggerForm fields: + delay: String(args.delay_seconds), + total_num_triggers: String(args.total_num_triggers ?? 1), + trigger_from_first_message: args.trigger_from_first_message ? 'on' : '', + // EventActionForm field: + action_type: args.action_type, + }; + // Merge action-params form fields (stringified — Django form parses POST strings) + for (const [k, v] of Object.entries(args.action_params ?? {})) { + body[k] = String(v); + } + const res = await this.opts.request( + 'POST', + path, + body, + { followRedirects: false, formEncoded: true }, + ); + if (res.status === 302) { + return { ok: true }; + } + if (res.status === 200) { + // Form re-render — one or more of the three forms failed validation. + // The HTML template renders errors per-field; parsing them out is + // template-fragile. For V1 we surface a generic error with a body + // sample so the operator can adjust inputs. + const html = res.text ? (await res.text()).slice(0, 600) : ''; + throw new Error( + `addChatbotEvent: form re-render (200) — one of TimeoutTriggerForm, EventActionForm, or the ${args.action_type} action-params form failed validation. First 400 chars: ${html.slice(0, 400)}`, + ); + } + throw await httpErrorFor(res, path); + } + + /** + * Create a custom action (an OpenAPI-driven external tool the LLM can + * call). POST /a//actions/new/ via the CSRF-protected + * CustomActionForm (apps/custom_actions/forms.py + views.py). + * + * Required: name, server_url, api_schema (an OpenAPI 3.x schema as + * JSON or YAML string). Optional: description, prompt, auth_provider, + * healthcheck_path. + * + * Success: 302 to /a//team/ (single_team:manage_team) — the + * Location header does NOT include the new action's id. To return the + * id, this atom follows up with a GET on /a//actions/ and + * scrapes the row whose name matches. If multiple actions share the + * same name, returns the most recent (highest id). + * + * Verified against /tmp/ace-refs/ocs/apps/custom_actions/views.py + + * forms.py (2026-05-21). + */ + async addCustomAction(args: { + name: string; + server_url: string; + api_schema: string; + description?: string; + prompt?: string; + healthcheck_path?: string; + }): Promise<{ action_id: number }> { + const newPath = `/a/${this.opts.teamSlug}/actions/new/`; + const body: Record = { + csrfmiddlewaretoken: this.opts.csrfToken, + name: args.name, + server_url: args.server_url, + api_schema: args.api_schema, + description: args.description ?? '', + prompt: args.prompt ?? '', + healthcheck_path: args.healthcheck_path ?? '', + }; + const res = await this.opts.request( + 'POST', newPath, body, { followRedirects: false, formEncoded: true }, + ); + if (res.status !== 302 && !res.ok) { + throw await httpErrorFor(res, newPath); + } + if (res.status === 200) { + const html = res.text ? (await res.text()).slice(0, 800) : ''; + throw new Error( + `addCustomAction: form re-render (200) — validation failed. Common causes: api_schema is not valid JSON/YAML, server_url is not a valid URL, or name is missing. First 400 chars: ${html.slice(0, 400)}`, + ); + } + // Successful 302. Now scrape the actions TABLE (not the home — the + // home renders a React shell; the table view at /actions/table/ + // returns the actual rendered rows). Real-row format verified + // against live OCS: + // {name} + const tablePath = `/a/${this.opts.teamSlug}/actions/table/`; + const listRes = await this.opts.request('GET', tablePath); + if (!listRes.ok || !listRes.text) throw await httpErrorFor(listRes, tablePath); + const listHtml = await listRes.text(); + const re = new RegExp(`href="/a/${this.opts.teamSlug}/actions/(\\d+)/">${escapeRegex(args.name)}<`, 'g'); + const ids: number[] = []; + for (const m of listHtml.matchAll(re)) { + ids.push(Number(m[1])); + } + if (ids.length === 0) { + throw new Error( + `addCustomAction created the action (302 redirect received) but could not find it by name "${args.name}" on the list page. Template may have changed; check the HTML structure.`, + ); + } + return { action_id: Math.max(...ids) }; + } + async cloneChatbot(args: { template_id: number; new_name: string }): Promise { // Step 1: POST the copy form. `copy_chatbot` in apps/chatbots/views.py:772 // parses request.POST (form-encoded) and returns a 302 redirect to diff --git a/mcp/ocs/client.ts b/mcp/ocs/client.ts index a7e4fb13..3fb2a399 100644 --- a/mcp/ocs/client.ts +++ b/mcp/ocs/client.ts @@ -42,6 +42,51 @@ export interface OcsClient { disconnect_edge?: { source: string; target: string }; }): Promise<{ node_id: string }>; + /** + * Attach a timeout-trigger event to a chatbot. + * POSTs the combined TimeoutTriggerForm + EventActionForm + action-params + * to /a//chatbots//events/timeout/new/. The view + * doesn't return the trigger ID, so this atom returns `{ok: true}` only. + */ + addChatbotEvent(args: { + experiment_id: number; + delay_seconds: number; + total_num_triggers?: number; + trigger_from_first_message?: boolean; + action_type: 'log' | 'send_message_to_bot' | 'end_conversation' | 'schedule_trigger' | 'pipeline_start'; + action_params?: Record; + }): Promise<{ ok: true }>; + + /** + * Create a Custom Action (OpenAPI-driven external tool the LLM can call). + * POSTs the CustomActionForm to /a//actions/new/. Required: + * name, server_url, api_schema (OpenAPI 3.x as JSON/YAML string). + * Optional: description, prompt, healthcheck_path. Returns the new + * action_id, found by scraping the actions home page (the create view + * doesn't include the id in the redirect Location). + */ + addCustomAction(args: { + name: string; + server_url: string; + api_schema: string; + description?: string; + prompt?: string; + healthcheck_path?: string; + }): Promise<{ action_id: number }>; + + /** + * Link a custom-action operation to a pipeline node. The node's + * `params.custom_actions` array gets `:` + * appended (per OCS's apps/custom_actions/form_utils.py:make_model_id). + * Typically the target node is an LLMResponseWithPrompt. + */ + linkActionToNode(args: { + pipeline_id: number; + node_id: string; + custom_action_id: number; + operation_id: string; + }): Promise<{ ok: true }>; + setChatbotSystemPrompt(args: { experiment_id: number; prompt: string; diff --git a/package.json b/package.json index 50dc7252..d7f87b51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.316", + "version": "0.13.317", "description": "AI Connect Engine - orchestrator for building Connect Opps using AI", "type": "module", "scripts": { diff --git a/scripts/probe-ocs-add-chatbot-event.ts b/scripts/probe-ocs-add-chatbot-event.ts new file mode 100644 index 00000000..a583e380 --- /dev/null +++ b/scripts/probe-ocs-add-chatbot-event.ts @@ -0,0 +1,98 @@ +/** + * Probe: attach a 24hr inactivity-timeout event to the V1 stub chatbot + * via the new `ocs_add_chatbot_event` atom. + * + * Default target: experiment 12213 (ACE Interviews Stub Template). + * Event: timeout, 24hr, action_type=log (simplest valid choice for V1). + * + * For real Connect Interviews bots the action_type would be + * `pipeline_start` pointing at the secondary "expiry" pipeline (see + * docs/connect-interviews/ocs-verification.md § "Architectural mismatch"). + * + * Run from worktree root: + * npx tsx scripts/probe-ocs-add-chatbot-event.ts # dry-run (peek events) + * npx tsx scripts/probe-ocs-add-chatbot-event.ts --commit # attach the event + */ +import 'dotenv/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { PlaywrightSession } from '../mcp/ocs/auth/playwright-session.js'; +import { PlaywrightBackend } from '../mcp/ocs/backends/playwright.js'; +import type { RequestFn } from '../mcp/ocs/backends/pipeline-patch.js'; + +const COMMIT = process.argv.includes('--commit'); +const EXPERIMENT_ID = Number(process.env.PROBE_EXPERIMENT_ID ?? 12213); + +if (!process.env.OCS_USERNAME) { + const envPath = path.join(os.homedir(), '.claude/plugins/data/ace-ace/.env'); + if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (m) process.env[m[1]] = m[2].replace(/^"(.*)"$/, '$1'); + } + } +} + +const baseUrl = process.env.OCS_BASE_URL ?? 'https://www.openchatstudio.com'; +const teamSlug = process.env.OCS_TEAM_SLUG!; +if (!teamSlug) throw new Error('OCS_TEAM_SLUG missing from env'); + +console.log(`[probe-ocs-add-chatbot-event]`); +console.log(` Team: ${teamSlug}`); +console.log(` Experiment: ${EXPERIMENT_ID}`); +console.log(` Commit: ${COMMIT ? 'YES — will attach 24hr timeout event' : 'no (dry run)'}`); +console.log(''); + +const session = new PlaywrightSession({ + baseUrl, teamSlug, + username: process.env.OCS_USERNAME!, + password: process.env.OCS_PASSWORD!, +}); + +try { + const ctx = await session.getContext(); + const csrfToken = session.getCsrfToken(); + const request: RequestFn = async (method, url, body, options) => { + const maxRedirects = options?.followRedirects === false ? 0 : undefined; + const headers = { 'X-CSRFToken': csrfToken, Referer: baseUrl }; + const fullUrl = url.startsWith('http') ? url : `${baseUrl}${url}`; + let res; + if (method === 'GET') res = await ctx.request.get(fullUrl, { maxRedirects }); + else if (options?.multipart) res = await ctx.request.post(fullUrl, { headers, multipart: options.multipart as any, maxRedirects }); + else if (options?.formEncoded) res = await ctx.request.post(fullUrl, { headers, form: body as Record, maxRedirects }); + else res = await ctx.request.post(fullUrl, { headers, data: body as any, maxRedirects }); + return { + ok: res.ok(), status: res.status(), headers: res.headers(), + text: async () => await res.text(), json: async () => await res.json(), + }; + }; + + // Peek the chatbot home page first to confirm session and reach. + const peek = await ctx.request.get(`${baseUrl}/a/${teamSlug}/chatbots/${EXPERIMENT_ID}/`, { maxRedirects: 0 }); + console.log(`GET chatbot home → ${peek.status()}`); + if (peek.status() !== 200) { + console.error(`Unexpected — expected 200. Body: ${(await peek.text()).slice(0, 200)}`); + process.exit(1); + } + + if (!COMMIT) { + console.log('[dry-run] Re-run with --commit to attach the event.'); + process.exit(0); + } + + const backend = new PlaywrightBackend({ teamSlug, baseUrl, csrfToken, request }); + console.log(`Attaching 24hr timeout event (action_type=log)...`); + const result = await backend.addChatbotEvent({ + experiment_id: EXPERIMENT_ID, + delay_seconds: 86400, // 24 hours + total_num_triggers: 1, + trigger_from_first_message: false, + action_type: 'log', + }); + console.log(` ✓ ${JSON.stringify(result)}`); + console.log(''); + console.log(`Visit: ${baseUrl}/a/${teamSlug}/chatbots/${EXPERIMENT_ID}/#events to verify`); +} finally { + await session.close().catch(() => {}); +} diff --git a/scripts/probe-ocs-add-custom-action.ts b/scripts/probe-ocs-add-custom-action.ts new file mode 100644 index 00000000..703c199d --- /dev/null +++ b/scripts/probe-ocs-add-custom-action.ts @@ -0,0 +1,129 @@ +/** + * Probe: create the V1 stub "Session Completion" custom action that the + * ACE Interviews Stub Template bot will eventually be wired to call. + * + * Per docs/connect-interviews/ocs-verification.md, OCS custom actions + * are OpenAPI-driven (not simple webhook configs). For V1 stub we use + * a placeholder OpenAPI schema pointing at a stand-in HQ inbound API + * URL — the real URL will be substituted per-domain by the + * /ace:interview-domain-bootstrap skill once the HQ inbound API + * Playwright atoms are built. + * + * Run from worktree root: + * npx tsx scripts/probe-ocs-add-custom-action.ts # dry-run + * npx tsx scripts/probe-ocs-add-custom-action.ts --commit # create the action + */ +import 'dotenv/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { PlaywrightSession } from '../mcp/ocs/auth/playwright-session.js'; +import { PlaywrightBackend } from '../mcp/ocs/backends/playwright.js'; +import type { RequestFn } from '../mcp/ocs/backends/pipeline-patch.js'; + +const COMMIT = process.argv.includes('--commit'); + +if (!process.env.OCS_USERNAME) { + const envPath = path.join(os.homedir(), '.claude/plugins/data/ace-ace/.env'); + if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (m) process.env[m[1]] = m[2].replace(/^"(.*)"$/, '$1'); + } + } +} + +const baseUrl = process.env.OCS_BASE_URL ?? 'https://www.openchatstudio.com'; +const teamSlug = process.env.OCS_TEAM_SLUG!; +if (!teamSlug) throw new Error('OCS_TEAM_SLUG missing from env'); + +// V1 stub: minimal OpenAPI 3.0 schema pointing at a placeholder HQ +// inbound API URL. Real cohorts substitute the actual URL per-domain. +const SESSION_COMPLETION_SCHEMA = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'HQ Session Completion (Connect Interviews — V1 stub)', version: '1.0.0' }, + servers: [{ url: 'https://www.commcarehq.org' }], + paths: { + '/a/{domain}/api/inbound_api/{api_id}/': { + post: { + operationId: 'postSessionCompletion', + description: 'Post session_completion + last_bot_interaction_date + interaction_validation back to HQ.', + parameters: [ + { name: 'domain', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'api_id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + session_completion: { type: 'string', enum: ['session completed', 'session incomplete'] }, + last_bot_interaction_date: { type: 'string', format: 'date' }, + interaction_validation: { type: 'string' }, + }, + required: ['session_completion', 'last_bot_interaction_date', 'interaction_validation'], + }, + }, + }, + }, + responses: { '200': { description: 'ok' } }, + }, + }, + }, +}); + +const ACTION_NAME = 'ACE Interviews — Session Completion (V1 stub)'; + +console.log(`[probe-ocs-add-custom-action]`); +console.log(` Team: ${teamSlug}`); +console.log(` Action name: ${ACTION_NAME}`); +console.log(` Commit: ${COMMIT ? 'YES — will create the action' : 'no (dry run)'}`); +console.log(''); + +const session = new PlaywrightSession({ + baseUrl, teamSlug, + username: process.env.OCS_USERNAME!, + password: process.env.OCS_PASSWORD!, +}); + +try { + const ctx = await session.getContext(); + const csrfToken = session.getCsrfToken(); + const request: RequestFn = async (method, url, body, options) => { + const maxRedirects = options?.followRedirects === false ? 0 : undefined; + const headers = { 'X-CSRFToken': csrfToken, Referer: baseUrl }; + const fullUrl = url.startsWith('http') ? url : `${baseUrl}${url}`; + let res; + if (method === 'GET') res = await ctx.request.get(fullUrl, { maxRedirects }); + else if (options?.multipart) res = await ctx.request.post(fullUrl, { headers, multipart: options.multipart as any, maxRedirects }); + else if (options?.formEncoded) res = await ctx.request.post(fullUrl, { headers, form: body as Record, maxRedirects }); + else res = await ctx.request.post(fullUrl, { headers, data: body as any, maxRedirects }); + return { + ok: res.ok(), status: res.status(), headers: res.headers(), + text: async () => await res.text(), json: async () => await res.json(), + }; + }; + + if (!COMMIT) { + const r = await ctx.request.get(`${baseUrl}/a/${teamSlug}/actions/new/`, { maxRedirects: 0 }); + console.log(`[dry-run] GET /a/${teamSlug}/actions/new/ → ${r.status()}`); + console.log('Re-run with --commit to actually create.'); + process.exit(0); + } + + const backend = new PlaywrightBackend({ teamSlug, baseUrl, csrfToken, request }); + console.log('Creating custom action...'); + const result = await backend.addCustomAction({ + name: ACTION_NAME, + server_url: 'https://www.commcarehq.org', + api_schema: SESSION_COMPLETION_SCHEMA, + description: 'Post session_completion back to CommCare HQ Inbound API. V1 stub; real cohorts substitute the actual {domain} + {api_id} per HQ inbound API config.', + prompt: 'When the user has answered all interview questions and the interview is complete, call this action to record session completion back to CommCare HQ.', + }); + console.log(` ✓ action_id = ${result.action_id}`); + console.log(` URL: ${baseUrl}/a/${teamSlug}/actions/${result.action_id}/`); +} finally { + await session.close().catch(() => {}); +} diff --git a/scripts/probe-ocs-link-action-to-node.ts b/scripts/probe-ocs-link-action-to-node.ts new file mode 100644 index 00000000..eb4a8bad --- /dev/null +++ b/scripts/probe-ocs-link-action-to-node.ts @@ -0,0 +1,101 @@ +/** + * Probe: wire the V1 stub "Session Completion" custom action (id 35) to + * the LLM node in the ACE Interviews Stub Template's pipeline (5981). + * + * After this probe: + * stub bot's LLM node `data.params.custom_actions` = ["35:postSessionCompletion"] + * + * Run from worktree root: + * npx tsx scripts/probe-ocs-link-action-to-node.ts # dry-run + * npx tsx scripts/probe-ocs-link-action-to-node.ts --commit # link + */ +import 'dotenv/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { PlaywrightSession } from '../mcp/ocs/auth/playwright-session.js'; +import { PlaywrightBackend } from '../mcp/ocs/backends/playwright.js'; +import type { RequestFn } from '../mcp/ocs/backends/pipeline-patch.js'; + +const COMMIT = process.argv.includes('--commit'); +const PIPELINE_ID = Number(process.env.PROBE_PIPELINE_ID ?? 5981); +const NODE_ID = process.env.PROBE_NODE_ID ?? 'LLMResponseWithPrompt-45d67'; +const ACTION_ID = Number(process.env.PROBE_ACTION_ID ?? 35); +const OPERATION_ID = process.env.PROBE_OPERATION_ID ?? 'postSessionCompletion'; + +if (!process.env.OCS_USERNAME) { + const envPath = path.join(os.homedir(), '.claude/plugins/data/ace-ace/.env'); + if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (m) process.env[m[1]] = m[2].replace(/^"(.*)"$/, '$1'); + } + } +} + +const baseUrl = process.env.OCS_BASE_URL ?? 'https://www.openchatstudio.com'; +const teamSlug = process.env.OCS_TEAM_SLUG!; +if (!teamSlug) throw new Error('OCS_TEAM_SLUG missing from env'); + +console.log(`[probe-ocs-link-action-to-node]`); +console.log(` Pipeline: ${PIPELINE_ID}`); +console.log(` Node: ${NODE_ID}`); +console.log(` Action id: ${ACTION_ID}`); +console.log(` Operation: ${OPERATION_ID}`); +console.log(` Commit: ${COMMIT ? 'YES — will link' : 'no (dry run, peek node)'}`); +console.log(''); + +const session = new PlaywrightSession({ + baseUrl, teamSlug, + username: process.env.OCS_USERNAME!, + password: process.env.OCS_PASSWORD!, +}); + +try { + const ctx = await session.getContext(); + const csrfToken = session.getCsrfToken(); + const request: RequestFn = async (method, url, body, options) => { + const maxRedirects = options?.followRedirects === false ? 0 : undefined; + const headers = { 'X-CSRFToken': csrfToken, Referer: baseUrl }; + const fullUrl = url.startsWith('http') ? url : `${baseUrl}${url}`; + let res; + if (method === 'GET') res = await ctx.request.get(fullUrl, { maxRedirects }); + else if (options?.multipart) res = await ctx.request.post(fullUrl, { headers, multipart: options.multipart as any, maxRedirects }); + else if (options?.formEncoded) res = await ctx.request.post(fullUrl, { headers, form: body as Record, maxRedirects }); + else res = await ctx.request.post(fullUrl, { headers, data: body as any, maxRedirects }); + return { + ok: res.ok(), status: res.status(), headers: res.headers(), + text: async () => await res.text(), json: async () => await res.json(), + }; + }; + + // Peek current node state + const peek = await ctx.request.get(`${baseUrl}/a/${teamSlug}/pipelines/data/${PIPELINE_ID}/`); + const data = await peek.json() as any; + const node = data.pipeline.data.nodes.find((n: any) => n.id === NODE_ID); + console.log(`Before — ${NODE_ID}.data.params.custom_actions:`, + JSON.stringify(node?.data?.params?.custom_actions ?? null)); + + if (!COMMIT) { + console.log('[dry-run] Re-run with --commit to link.'); + process.exit(0); + } + + const backend = new PlaywrightBackend({ teamSlug, baseUrl, csrfToken, request }); + const result = await backend.linkActionToNode({ + pipeline_id: PIPELINE_ID, + node_id: NODE_ID, + custom_action_id: ACTION_ID, + operation_id: OPERATION_ID, + }); + console.log(` ✓ ${JSON.stringify(result)}`); + + // Confirm + const after = await ctx.request.get(`${baseUrl}/a/${teamSlug}/pipelines/data/${PIPELINE_ID}/`); + const afterData = await after.json() as any; + const afterNode = afterData.pipeline.data.nodes.find((n: any) => n.id === NODE_ID); + console.log(`After — ${NODE_ID}.data.params.custom_actions:`, + JSON.stringify(afterNode?.data?.params?.custom_actions ?? null)); +} finally { + await session.close().catch(() => {}); +}