diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6edb337..2cffd56 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ { "name": "ace", "source": "./", - "version": "0.13.278", + "version": "0.13.279", "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 47b1b7a..68565c1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.278", + "version": "0.13.279", "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/CHANGELOG.md b/CHANGELOG.md index 49c8b83..db3c897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to the ACE plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the plugin follows [semantic versioning](https://semver.org/spec/v2.0.0.html). +## 0.13.279 — 2026-05-19 + +**New `ocs_get_chatbot_pipeline_id` atom — closes last orphan-storage class on OCS sweep.** + +Before this PR, `/ace:sweep ocs` could delete an orphan chatbot but had no way to discover its paired Pipeline row's id. The existing `PlaywrightBackend.pipelineIdFor` method (used internally by `cloneChatbot` + the pipeline-patch atoms) already scrapes the pipeline-builder HTML — but it wasn't exposed as an MCP atom, so sweep skills couldn't call it. Result: every chatbot delete left a zombie Pipeline row (`is_archived=False`, no parent chatbot in the live listing). Surfaced live in the 2026-05-18 OCS sweep that cleaned up 9 orphan chatbots + 12 collections but had to skip all 10 paired pipelines. + +Fix: thin one-line wrapper exposing the existing internal method. + +- `mcp/ocs/client.ts` — new `getChatbotPipelineId({ experiment_id }) → { pipeline_id }` capability. +- `mcp/ocs/backends/composite.ts` — delegates to `playwright.pipelineIdFor(experiment_id)`. +- `mcp/ocs/capability-map.ts` — new `get_chatbot_pipeline_id` entry routed to Playwright with `restTarget` noted as "pipeline_id field not yet exposed in REST schema" (lock-step swap if OCS adds it upstream). +- `mcp/ocs-server.ts` — registers the new `ocs_get_chatbot_pipeline_id` tool. Description explains the use case (sweep wiring) and the OCS-side gap (REST schema omits pipeline_id). +- `skills/sweep-ocs/SKILL.md § Process` — step 3 now calls `ocs_get_chatbot_pipeline_id` per chatbot; step 9 explicitly warns against skipping the pipeline delete. +- `test/mcp/ocs/composite.test.ts` — new case asserts the composite routes `getChatbotPipelineId` to `pipelineIdFor` and wraps the integer in `{ pipeline_id }`. 9/9 tests pass. + +The 10 zombie pipelines from the 2026-05-18 sweep (chatbots 11839, 11994, 11996, 12000, 12003, 12018, 12042, 12050, 12101 + historical 12027 versions v2-v11) can be cleaned up in a follow-up sweep with the new atom in place. + ## 0.13.277 — 2026-05-18 **Mirror Vellum's slug/name separation in the Nova architect brief (follow-up to 0.13.274).** diff --git a/VERSION b/VERSION index cbeec60..2054863 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.278 +0.13.279 diff --git a/mcp/ocs-server.ts b/mcp/ocs-server.ts index 8084515..befb72f 100644 --- a/mcp/ocs-server.ts +++ b/mcp/ocs-server.ts @@ -359,6 +359,13 @@ server.tool( async (args) => result(await composite.deleteChatbot(args)), ); +server.tool( + 'ocs_get_chatbot_pipeline_id', + 'Resolve an experiment_id (integer chatbot id) to its working-version pipeline_id (integer). The OCS REST `/api/experiments//` response omits pipeline_id by design; this atom scrapes it from the pipeline-builder HTML (`SiteJS.pipeline.renderPipeline("#pipelineBuilder", "", )`) via Playwright and caches the result per experiment_id. Used by /ace:sweep ocs to pair each orphan chatbot with its per-opp Pipeline row before deletion — without this, deleting an orphan chatbot leaves its Pipeline as a zombie row on the team (is_archived=False, no parent chatbot in the live listing). Returns `{ pipeline_id: number }`.', + { experiment_id: z.number().int() }, + async (args) => result(await composite.getChatbotPipelineId(args)), +); + server.tool( 'ocs_delete_pipeline', 'Delete a pipeline (sets is_archived=True server-side). SAFE PER-OPP: when ACE clones a chatbot, Pipeline.create_new_version(is_copy=True) deep-clones the Pipeline row + its nodes — each clone has its own pipeline. Deleting the pipeline does NOT cascade-delete its referenced Collections — those need separate ocs_delete_collection calls. Routes through Playwright to /a//pipelines//delete/ (HTTP DELETE method on Django View.delete(); returns 200 empty body).', diff --git a/mcp/ocs/backends/composite.ts b/mcp/ocs/backends/composite.ts index fbfd34d..597c59f 100644 --- a/mcp/ocs/backends/composite.ts +++ b/mcp/ocs/backends/composite.ts @@ -33,6 +33,9 @@ export class CompositeBackend implements OcsClient { publishChatbotVersion = (a: Parameters[0]) => this.opts.playwright.publishChatbotVersion(a); getChatbotEmbedInfo = (a: Parameters[0]) => this.opts.playwright.getChatbotEmbedInfo(a); deleteChatbot = (a: Parameters[0]) => this.opts.playwright.deleteChatbot(a); + getChatbotPipelineId = async (a: Parameters[0]) => ({ + pipeline_id: await this.opts.playwright.pipelineIdFor(a.experiment_id), + }); deletePipeline = (a: Parameters[0]) => this.opts.playwright.deletePipeline(a); deleteCollection = (a: Parameters[0]) => this.opts.playwright.deleteCollection(a); diff --git a/mcp/ocs/capability-map.ts b/mcp/ocs/capability-map.ts index c485175..d373493 100644 --- a/mcp/ocs/capability-map.ts +++ b/mcp/ocs/capability-map.ts @@ -19,6 +19,7 @@ export type Capability = | 'publish_chatbot_version' | 'get_chatbot_embed_info' | 'delete_chatbot' + | 'get_chatbot_pipeline_id' | 'delete_pipeline' | 'delete_collection' // Observation (12) @@ -49,6 +50,7 @@ export const CAPABILITY_MAP: Record = { publish_chatbot_version: { backend: 'PLAYWRIGHT', restTarget: 'POST /api/experiments/{id}/versions/' }, get_chatbot_embed_info: { backend: 'HYBRID', restTarget: 'GET /api/experiments/{id}/embed/' }, delete_chatbot: { backend: 'PLAYWRIGHT', restTarget: 'DELETE /api/experiments/{id}/ (not yet shipped)' }, + get_chatbot_pipeline_id: { backend: 'PLAYWRIGHT', restTarget: 'GET /api/experiments/{id}/ (pipeline_id field not yet exposed in REST schema)' }, delete_pipeline: { backend: 'PLAYWRIGHT', restTarget: 'DELETE /api/pipelines/{id}/ (not yet shipped)' }, delete_collection: { backend: 'PLAYWRIGHT', restTarget: 'DELETE /api/collections/{id}/ (not yet shipped)' }, diff --git a/mcp/ocs/client.ts b/mcp/ocs/client.ts index 31ca536..7c0067c 100644 --- a/mcp/ocs/client.ts +++ b/mcp/ocs/client.ts @@ -116,6 +116,21 @@ export interface OcsClient { */ deleteChatbot(args: { experiment_id: number }): Promise<{ deleted: number }>; + /** + * Resolve an experiment_id (integer chatbot id) to its working-version + * pipeline_id (integer). Required by `/ace:sweep ocs` to pair each orphan + * chatbot with its per-opp Pipeline row before deletion — without this, + * deleting an orphan chatbot leaves its Pipeline as a zombie row on the + * team (`is_archived=False`, no parent chatbot in the live listing). + * + * OCS's REST `/api/experiments//` response intentionally omits + * `pipeline_id`; the value is rendered inline in the pipeline-builder + * HTML page as `SiteJS.pipeline.renderPipeline("#pipelineBuilder", ..., + * )`. This atom scrapes that page (Playwright-backed) and + * caches the result per experiment_id. + */ + getChatbotPipelineId(args: { experiment_id: number }): Promise<{ pipeline_id: number }>; + /** * Delete a pipeline by integer id. Sets `is_archived=True` on the Pipeline * row server-side. Each clone gets its own Pipeline (verified 2026-05-15: diff --git a/package-lock.json b/package-lock.json index e22116a..a2caa1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ace", - "version": "0.13.276", + "version": "0.13.279", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ace", - "version": "0.13.276", + "version": "0.13.279", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.94.0", diff --git a/package.json b/package.json index f49bd23..2921a85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.278", + "version": "0.13.279", "description": "AI Connect Engine - orchestrator for building Connect Opps using AI", "type": "module", "scripts": { diff --git a/skills/sweep-ocs/SKILL.md b/skills/sweep-ocs/SKILL.md index ffaae57..9e07954 100644 --- a/skills/sweep-ocs/SKILL.md +++ b/skills/sweep-ocs/SKILL.md @@ -52,7 +52,7 @@ No dedup at the file or vector layer means uploading the same PDD into 19 per-op 3. **List OCS inventory** using existing atoms: - `ocs_list_chatbots` (paginate as needed) - `ocs_list_sessions` (filter to `OCS_TEAM_SLUG`; last 90 days by default). - - For each chatbot, derive its pipeline id and per-opp collection ids from the chatbot's published version description / pipeline definition. The chatbot's most-recent version description embeds the per-opp collection id explicitly (e.g. "shared Connect collection 350 + new per-run collection 418"). Parse it; cross-check by reading the pipeline's LLM node params for collection refs. + - For each chatbot, derive its pipeline_id via `ocs_get_chatbot_pipeline_id({ experiment_id })` (added 0.13.277 — scrapes the pipeline-builder HTML; cached). Per-opp collection ids come from the chatbot's most-recent published version description (e.g. "shared Connect collection 350 + new per-run collection 418"); cross-check against the pipeline's LLM node params via the pipeline-builder page if the description is thin. 4. **Diff** each item's id against the live-set: - chatbots → `liveSet.identifiers.ocsChatbotIds` (now includes golden template). - sessions → `liveSet.identifiers.ocsSessionIds`. @@ -67,7 +67,7 @@ No dedup at the file or vector layer means uploading the same PDD into 19 per-op 8. **Surface to human** in chat. Prompt for approval per actionable chunk. 9. **On approval (in order, per orphan chatbot):** - Call `ocs_delete_chatbot({ experiment_id })`. - - Call `ocs_delete_pipeline({ pipeline_id })` for the paired pipeline. + - Call `ocs_delete_pipeline({ pipeline_id })` for the paired pipeline (resolved via `ocs_get_chatbot_pipeline_id` in step 3 — never skip this; pipeline deletes are the only way to clear the per-opp Pipeline row). - For each per-opp collection_id derived in step 3, call `ocs_delete_collection({ collection_id })` — but ONLY if collection_id ≠ `OCS_SHARED_COLLECTION_ID`. The atom itself doesn't enforce this; the skill must filter. - For each approved orphan session, call `ocs_end_session`. diff --git a/test/mcp/ocs/composite.test.ts b/test/mcp/ocs/composite.test.ts index 2b0b6bd..00cf93c 100644 --- a/test/mcp/ocs/composite.test.ts +++ b/test/mcp/ocs/composite.test.ts @@ -25,4 +25,13 @@ describe('CompositeBackend routing', () => { await c.getChatbotEmbedInfo({ experiment_id: 1 }); expect(pw.getChatbotEmbedInfo).toHaveBeenCalled(); }); + + it('getChatbotPipelineId calls playwright.pipelineIdFor and wraps the result', async () => { + const rest = {}; + const pw = { pipelineIdFor: vi.fn().mockResolvedValue(5942) }; + const c = new CompositeBackend({ rest: rest as never, playwright: pw as never }); + const out = await c.getChatbotPipelineId({ experiment_id: 12167 }); + expect(pw.pipelineIdFor).toHaveBeenCalledWith(12167); + expect(out).toEqual({ pipeline_id: 5942 }); + }); });