From 6eb7e3de6d18aca12f0d8882d1d68128f06b1347 Mon Sep 17 00:00:00 2001 From: Kristin Komschow <162317489+kristinkomschow@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:28:31 +0200 Subject: [PATCH] feat(process-instance): add suspend and resume commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `c8ctl suspend process-instance ` and `c8ctl resume process-instance `, wired through the command registry and dispatch map, calling the SDK's suspendProcessInstance / resumeProcessInstance methods (POST /process-instances/{key}/suspension and /resumption). The REST endpoint only exists starting with Camunda 8.10 — 8.8/8.9 gateways 404 on this path. Documented in .github/SDK_GAPS.md, noted in the command help text ("Camunda 8.10+"), and the live integration tests are version-gated accordingly. Covered by: - behavioural unit tests (dry-run + missing-key cases) - live CLI integration tests (deploy a user-task process, suspend, poll for SUSPENDED via `get pi`, resume, poll for ACTIVE), gated to Camunda 8.10+ README.md and docs/command-reference.md regenerated from the registry. Closes camunda/camunda#57996 Co-Authored-By: Claude Sonnet 5 --- .github/SDK_GAPS.md | 7 ++ README.md | 2 + docs/command-reference.md | 28 ++++++ src/command-dispatch.ts | 4 + src/commands/process-instances.ts | 48 ++++++++++ src/framework/command-registry.ts | 30 ++++++ tests/integration/process-instances.test.ts | 94 +++++++++++++++++++ tests/unit/command-registry.test.ts | 4 + .../unit/process-instances-behaviour.test.ts | 50 ++++++++++ 9 files changed, 267 insertions(+) diff --git a/.github/SDK_GAPS.md b/.github/SDK_GAPS.md index 349e2f91..68e5d269 100644 --- a/.github/SDK_GAPS.md +++ b/.github/SDK_GAPS.md @@ -14,6 +14,13 @@ When a new SDK limitation is discovered during development, add it here followin ## Open Gaps +- [ ] **Process instance suspend/resume endpoints not available before Camunda 8.10** + - **SDK:** `@camunda8/orchestration-cluster-api` — current version **10.0.0-alpha.43** + - **Behavior:** The SDK client exposes `suspendProcessInstance()`/`resumeProcessInstance()` (`POST /process-instances/{key}/suspension` and `/resumption`) and the types compile against every supported server version, but the REST API gateway on Camunda 8.8/8.9 returns `404 Not Found [suspendProcessInstance]: No endpoint POST /v2/process-instances/{key}/suspension.` — the endpoint only exists starting with 8.10. + - **Affected:** `c8ctl suspend process-instance` / `c8ctl resume process-instance` (#57996). + - **Impact:** Running either command against an 8.8/8.9 cluster surfaces the gateway's 404 as a normal command error (no special-casing needed — the framework's error path already renders it clearly). The live CLI integration tests for these commands (`tests/integration/process-instances.test.ts`) are skipped on `CAMUNDA_VERSION` 8.8/8.9 via `suspendResumeSkip`, mirroring the existing `businessIdSkip` pattern in the same file. + - **Remediation:** None needed — this is a platform version floor, not an SDK defect. Revisit only if a future SDK release needs adjusting for a change in the endpoint's shape. + - [ ] **No config flag to disable the automatic `/v2` suffix on `CAMUNDA_REST_ADDRESS`** - **SDK:** `@camunda8/orchestration-cluster-api` — current version **10.0.0-alpha.43** - **Behavior:** `hydrateConfig()` always appends `/v2` to `CAMUNDA_REST_ADDRESS` unless the value already ends with `/v2` or `/v2/`; there is no override. diff --git a/README.md b/README.md index bd4005f0..7a209364 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ c8ctl [arguments] [flags] - `create` - Create resource - `delete` - Delete resource - `cancel` - Cancel resource +- `suspend` - Suspend resource +- `resume` - Resume resource - `await` - Create and await completion (alias for create --awaitCompletion) - `complete` - Complete resource - `fail` - Fail a job diff --git a/docs/command-reference.md b/docs/command-reference.md index 0a3c0802..3369d8e4 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -626,6 +626,34 @@ Cancel a process instance --- +### `suspend` + +Suspend a process instance (Camunda 8.10+) + +**Usage:** `c8ctl suspend ` + +**Resources:** pi (process-instance) + +**Positional arguments:** + +- **process-instance:** `` (required) + +--- + +### `resume` + +Resume a suspended process instance (Camunda 8.10+) + +**Usage:** `c8ctl resume ` + +**Resources:** pi (process-instance) + +**Positional arguments:** + +- **process-instance:** `` (required) + +--- + ### `await` Create and await process instance completion (server-side waiting) diff --git a/src/command-dispatch.ts b/src/command-dispatch.ts index c25224a6..43bd6657 100644 --- a/src/command-dispatch.ts +++ b/src/command-dispatch.ts @@ -91,6 +91,8 @@ import { createProcessInstanceCommand, getProcessInstanceCommand, listProcessInstancesCommand, + resumeProcessInstanceCommand, + suspendProcessInstanceCommand, } from "./commands/process-instances.ts"; import { addProfileCommand, @@ -156,6 +158,8 @@ export const COMMAND_DISPATCH: ReadonlyMap = new Map< ["get:process-instance", getProcessInstanceCommand], ["create:process-instance", createProcessInstanceCommand], ["cancel:process-instance", cancelProcessInstanceCommand], + ["suspend:process-instance", suspendProcessInstanceCommand], + ["resume:process-instance", resumeProcessInstanceCommand], ["await:process-instance", awaitProcessInstanceCommand], // ── Process definitions ──────────────────────────────────────────── diff --git a/src/commands/process-instances.ts b/src/commands/process-instances.ts index 4ebc9fbf..9cb8d4dd 100644 --- a/src/commands/process-instances.ts +++ b/src/commands/process-instances.ts @@ -368,3 +368,51 @@ export const cancelProcessInstanceCommand = defineCommand( return { kind: "success", message: `Process instance ${key} cancelled` }; }, ); + +/** + * Suspend process instance + */ +export const suspendProcessInstanceCommand = defineCommand( + "suspend", + "process-instance", + async (ctx, _flags, args) => { + const { client, profile } = ctx; + const key = args.key; + + const dr = ctx.dryRun({ + command: "suspend process-instance", + method: "POST", + endpoint: `/process-instances/${key}/suspension`, + profile, + body: {}, + }); + if (dr) return dr; + + await client.suspendProcessInstance({ processInstanceKey: key }); + return { kind: "success", message: `Process instance ${key} suspended` }; + }, +); + +/** + * Resume process instance + */ +export const resumeProcessInstanceCommand = defineCommand( + "resume", + "process-instance", + async (ctx, _flags, args) => { + const { client, profile } = ctx; + const key = args.key; + + const dr = ctx.dryRun({ + command: "resume process-instance", + method: "POST", + endpoint: `/process-instances/${key}/resumption`, + profile, + body: {}, + }); + if (dr) return dr; + + await client.resumeProcessInstance({ processInstanceKey: key }); + return { kind: "success", message: `Process instance ${key} resumed` }; + }, +); diff --git a/src/framework/command-registry.ts b/src/framework/command-registry.ts index b35641fa..0dd796e4 100644 --- a/src/framework/command-registry.ts +++ b/src/framework/command-registry.ts @@ -1137,6 +1137,36 @@ export const COMMAND_REGISTRY = { }, }, + suspend: { + description: "Suspend resource", + helpDescription: "Suspend a process instance (Camunda 8.10+)", + helpResource: " ", + hasDetailedHelp: true, + helpFooterLabel: "Show suspend command with all flags", + mutating: true, + requiresResource: true, + resources: ["pi"], + flags: {}, + resourcePositionals: { + "process-instance": GET_PI_POSITIONALS, + }, + }, + + resume: { + description: "Resume resource", + helpDescription: "Resume a suspended process instance (Camunda 8.10+)", + helpResource: " ", + hasDetailedHelp: true, + helpFooterLabel: "Show resume command with all flags", + mutating: true, + requiresResource: true, + resources: ["pi"], + flags: {}, + resourcePositionals: { + "process-instance": GET_PI_POSITIONALS, + }, + }, + await: { description: "Create and await completion (alias for create --awaitCompletion)", diff --git a/tests/integration/process-instances.test.ts b/tests/integration/process-instances.test.ts index 9e4fcbce..adab51ca 100644 --- a/tests/integration/process-instances.test.ts +++ b/tests/integration/process-instances.test.ts @@ -19,6 +19,7 @@ import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, test } from "node:test"; import { ProcessDefinitionId } from "@camunda8/orchestration-cluster-api"; import { createClient } from "../../src/core/client.ts"; +import { parseJson } from "../utils/cli.ts"; import { todayRange } from "../utils/date-helpers.ts"; import { makeTestEnv } from "../utils/mocks.ts"; import { pollUntil } from "../utils/polling.ts"; @@ -32,6 +33,11 @@ const businessIdSkip = camundaVersion?.startsWith("8.8") === true ? `Business ID requires Camunda 8.9+ (CAMUNDA_VERSION=${camundaVersion})` : false; +const suspendResumeSkip = + camundaVersion?.startsWith("8.8") === true || + camundaVersion?.startsWith("8.9") === true + ? `Process instance suspend/resume requires Camunda 8.10+ (CAMUNDA_VERSION=${camundaVersion})` + : false; const PROJECT_ROOT = resolve(import.meta.dirname, "..", ".."); const CLI = join(PROJECT_ROOT, "src", "index.ts"); @@ -70,6 +76,17 @@ function parseItems(stdout: string): T[] { return JSON.parse(stdout) as T[]; } +/** Read a process instance's `state` via `get pi ` (JSON output mode). */ +async function getProcessInstanceStateViaCli( + testDir: string, + key: string, +): Promise { + const result = await cli(testDir, "get", "pi", key, "--fields", "state"); + if (result.status !== 0) return undefined; + const data = parseJson(result); + return typeof data.state === "string" ? data.state : undefined; +} + describe("Process Instance Integration Tests (requires Camunda 8 at localhost:8080)", () => { let testDir: string; let originalEnv: NodeJS.ProcessEnv; @@ -393,6 +410,83 @@ describe("Process Instance Integration Tests (requires Camunda 8 at localhost:80 } }); + test("suspend process instance CLI suspends a running instance", { + skip: suspendResumeSkip, + }, async () => { + await deploy(testDir, "tests/fixtures/simple-user-task.bpmn"); + const created = await client.createProcessInstance({ + processDefinitionId: ProcessDefinitionId.assumeExists("simple-user-task"), + }); + const instanceKey = created.processInstanceKey.toString(); + + await cli(testDir, "output", "json"); + + const suspendResult = await cli(testDir, "suspend", "pi", instanceKey); + assert.strictEqual( + suspendResult.status, + 0, + `suspend should succeed. stderr: ${suspendResult.stderr}`, + ); + + const suspended = await pollUntil( + async () => + (await getProcessInstanceStateViaCli(testDir, instanceKey)) === + "SUSPENDED", + POLL_TIMEOUT_MS, + POLL_INTERVAL_MS, + ); + assert.ok( + suspended, + "process instance should transition to SUSPENDED after suspend", + ); + }); + + test("resume process instance CLI resumes a suspended instance", { + skip: suspendResumeSkip, + }, async () => { + await deploy(testDir, "tests/fixtures/simple-user-task.bpmn"); + const created = await client.createProcessInstance({ + processDefinitionId: ProcessDefinitionId.assumeExists("simple-user-task"), + }); + const instanceKey = created.processInstanceKey.toString(); + + await cli(testDir, "output", "json"); + + const suspendResult = await cli(testDir, "suspend", "pi", instanceKey); + assert.strictEqual( + suspendResult.status, + 0, + `suspend setup failed. stderr: ${suspendResult.stderr}`, + ); + const suspended = await pollUntil( + async () => + (await getProcessInstanceStateViaCli(testDir, instanceKey)) === + "SUSPENDED", + POLL_TIMEOUT_MS, + POLL_INTERVAL_MS, + ); + assert.ok(suspended, "setup should reach SUSPENDED before resuming"); + + const resumeResult = await cli(testDir, "resume", "pi", instanceKey); + assert.strictEqual( + resumeResult.status, + 0, + `resume should succeed. stderr: ${resumeResult.stderr}`, + ); + + const resumed = await pollUntil( + async () => + (await getProcessInstanceStateViaCli(testDir, instanceKey)) === + "ACTIVE", + POLL_TIMEOUT_MS, + POLL_INTERVAL_MS, + ); + assert.ok( + resumed, + "process instance should transition back to ACTIVE after resume", + ); + }); + test("create with awaitCompletion returns completed result with variables", async () => { // Deploy a simple process first await deploy(testDir, "tests/fixtures/simple.bpmn"); diff --git a/tests/unit/command-registry.test.ts b/tests/unit/command-registry.test.ts index b3777fc5..3b272979 100644 --- a/tests/unit/command-registry.test.ts +++ b/tests/unit/command-registry.test.ts @@ -41,6 +41,8 @@ describe("COMMAND_REGISTRY completeness", () => { "create", "delete", "cancel", + "suspend", + "resume", "await", "complete", "fail", @@ -513,6 +515,8 @@ describe("mutating flag correctness", () => { "create", "delete", "cancel", + "suspend", + "resume", "await", "complete", "fail", diff --git a/tests/unit/process-instances-behaviour.test.ts b/tests/unit/process-instances-behaviour.test.ts index ecdeebc9..a631296a 100644 --- a/tests/unit/process-instances-behaviour.test.ts +++ b/tests/unit/process-instances-behaviour.test.ts @@ -217,3 +217,53 @@ describe("CLI behavioural: cancel process-instance", () => { ); }); }); + +// ─── suspend process-instance ──────────────────────────────────────────────── + +describe("CLI behavioural: suspend process-instance", () => { + test("--dry-run emits POST to suspension endpoint", async () => { + const result = await c8("suspend", "pi", "--dry-run", "12345"); + + assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`); + const out = parseJson(result); + + assert.strictEqual(out.dryRun, true); + assert.strictEqual(out.method, "POST"); + assert.ok(getUrl(out).includes("/process-instances/12345/suspension")); + }); + + test("rejects missing key with exit code 1", async () => { + const result = await c8("suspend", "pi"); + + assert.strictEqual(result.status, 1); + assert.ok( + result.stderr.includes("Process instance key required"), + `stderr: ${result.stderr}`, + ); + }); +}); + +// ─── resume process-instance ───────────────────────────────────────────────── + +describe("CLI behavioural: resume process-instance", () => { + test("--dry-run emits POST to resumption endpoint", async () => { + const result = await c8("resume", "pi", "--dry-run", "12345"); + + assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`); + const out = parseJson(result); + + assert.strictEqual(out.dryRun, true); + assert.strictEqual(out.method, "POST"); + assert.ok(getUrl(out).includes("/process-instances/12345/resumption")); + }); + + test("rejects missing key with exit code 1", async () => { + const result = await c8("resume", "pi"); + + assert.strictEqual(result.status, 1); + assert.ok( + result.stderr.includes("Process instance key required"), + `stderr: ${result.stderr}`, + ); + }); +});