Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/SDK_GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ c8ctl <verb> <resource> [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
Expand Down
28 changes: 28 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,34 @@ Cancel a process instance

---

### `suspend`

Suspend a process instance (Camunda 8.10+)

**Usage:** `c8ctl suspend <resource> <key>`

**Resources:** pi (process-instance)

**Positional arguments:**

- **process-instance:** `<key>` (required)

---

### `resume`

Resume a suspended process instance (Camunda 8.10+)

**Usage:** `c8ctl resume <resource> <key>`

**Resources:** pi (process-instance)

**Positional arguments:**

- **process-instance:** `<key>` (required)

---

### `await`

Create and await process instance completion (server-side waiting)
Expand Down
4 changes: 4 additions & 0 deletions src/command-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ import {
createProcessInstanceCommand,
getProcessInstanceCommand,
listProcessInstancesCommand,
resumeProcessInstanceCommand,
suspendProcessInstanceCommand,
} from "./commands/process-instances.ts";
import {
addProfileCommand,
Expand Down Expand Up @@ -156,6 +158,8 @@ export const COMMAND_DISPATCH: ReadonlyMap<string, AnyCommandHandler> = 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 ────────────────────────────────────────────
Expand Down
48 changes: 48 additions & 0 deletions src/commands/process-instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment thread
kristinkomschow marked this conversation as resolved.
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 });
Comment thread
kristinkomschow marked this conversation as resolved.
return { kind: "success", message: `Process instance ${key} resumed` };
},
);
30 changes: 30 additions & 0 deletions src/framework/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,36 @@ export const COMMAND_REGISTRY = {
},
},

suspend: {
description: "Suspend resource",
helpDescription: "Suspend a process instance (Camunda 8.10+)",
helpResource: "<resource> <key>",
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: "<resource> <key>",
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)",
Expand Down
94 changes: 94 additions & 0 deletions tests/integration/process-instances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -70,6 +76,17 @@ function parseItems<T>(stdout: string): T[] {
return JSON.parse(stdout) as T[];
}

/** Read a process instance's `state` via `get pi <key>` (JSON output mode). */
async function getProcessInstanceStateViaCli(
testDir: string,
key: string,
): Promise<string | undefined> {
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;
Expand Down Expand Up @@ -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"),
});
Comment thread
kristinkomschow marked this conversation as resolved.
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");
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/command-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ describe("COMMAND_REGISTRY completeness", () => {
"create",
"delete",
"cancel",
"suspend",
"resume",
"await",
"complete",
"fail",
Expand Down Expand Up @@ -513,6 +515,8 @@ describe("mutating flag correctness", () => {
"create",
"delete",
"cancel",
"suspend",
"resume",
"await",
"complete",
"fail",
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/process-instances-behaviour.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
});
});
Loading