feat(ql3): expose cancellation status card

This commit is contained in:
whyour
2026-08-19 09:21:50 +08:00
parent 5a979e510e
commit 095683a4cb
15 changed files with 741 additions and 32 deletions
@@ -49,6 +49,12 @@ export interface ClusterPluginPackageManagementClientPaths {
readonly assertionFile: string;
}
export interface ClusterAuthenticatedManagementCommandExecution<Command> {
readonly configFile: string;
readonly assertionFile: string;
readonly command: Command;
}
export interface ClusterPluginPackageManagementClientResult {
readonly schemaVersion: 1;
readonly requestId: string;
@@ -1119,11 +1125,23 @@ export async function executeClusterAuthenticatedManagementClient<
Command,
Result,
>(
paths: ClusterPluginPackageManagementClientPaths,
execution:
| ClusterPluginPackageManagementClientPaths
| ClusterAuthenticatedManagementCommandExecution<Command>,
protocol: ClusterAuthenticatedManagementClientProtocol<Command, Result>,
connectionOptions?: ClusterPluginPackageManagementClientConnectionOptions,
): Promise<Readonly<ClusterAuthenticatedManagementClientResult<Result>>> {
exactObject(paths, ['configFile', 'commandFile', 'assertionFile']);
const inlineCommand =
execution !== null &&
typeof execution === 'object' &&
!Array.isArray(execution) &&
Object.hasOwn(execution, 'command');
exactObject(
execution,
inlineCommand
? ['configFile', 'command', 'assertionFile']
: ['configFile', 'commandFile', 'assertionFile'],
);
if (
!protocol ||
typeof protocol !== 'object' ||
@@ -1160,17 +1178,19 @@ export async function executeClusterAuthenticatedManagementClient<
| undefined;
try {
prepared = prepareClusterAuthenticatedManagementClientConfiguration(
paths.configFile,
execution.configFile,
protocol.managementPath,
protocol.clientCertificate,
);
commandBytes = readCanonicalFile(
paths.commandFile,
MAX_COMMAND_BYTES,
'private',
);
if (!inlineCommand) {
commandBytes = readCanonicalFile(
(execution as ClusterPluginPackageManagementClientPaths).commandFile,
MAX_COMMAND_BYTES,
'private',
);
}
assertionBytes = readCanonicalFile(
paths.assertionFile,
execution.assertionFile,
MAX_ASSERTION_BYTES,
'private',
);
@@ -1183,7 +1203,12 @@ export async function executeClusterAuthenticatedManagementClient<
clientCertificateBytes,
clientPrivateKeyBytes,
} = prepared;
const command = protocol.normalizeCommand(parseJson(commandBytes));
const command = protocol.normalizeCommand(
inlineCommand
? (execution as ClusterAuthenticatedManagementCommandExecution<Command>)
.command
: parseJson(commandBytes!),
);
const assertion = assertionBytes.toString('ascii');
if (
assertionBytes.some((byte) => byte > 0x7f) ||
@@ -87,7 +87,7 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
name: 'run',
binary: 'ql3-run-client',
target: 'run-management/runManagementClientCli.js',
description: 'retry or stop Runs under strong authentication',
description: 'observe, retry or stop Runs under strong authentication',
}),
Object.freeze({
name: 'automation',
@@ -0,0 +1,127 @@
import { randomUUID } from 'node:crypto';
import type { ClusterRunManagementClientResult } from './runManagementClient';
import {
RUN_CANCELLATION_DISPATCH_SUMMARY_REQUEST_SCHEMA,
normalizeClusterRunManagementCommand,
type ClusterRunManagementCancellationSummaryCommand,
type ClusterRunManagementCancellationSummaryTransportResult,
} from './runManagementTransport';
export const RUN_CANCELLATION_STATUS_SCHEMA =
'qinglong/run-cancellation-status@v1' as const;
export type RunCancellationStatusExitCode = 0 | 10 | 20;
export type RunCancellationStatusSeverity = 'ok' | 'warning' | 'critical';
type CancellationSummary =
ClusterRunManagementCancellationSummaryTransportResult['summary'];
export interface RunCancellationStatusObservation {
readonly schemaVersion: 1;
readonly schema: typeof RUN_CANCELLATION_STATUS_SCHEMA;
readonly component: 'qinglong3-run-management-client';
readonly event: 'cancellation_status_observed';
readonly requestId: string;
readonly projectId: string;
readonly observedAtMs: number;
readonly assessment: CancellationSummary['assessment'];
readonly operatorAction: CancellationSummary['operatorAction'];
readonly severity: RunCancellationStatusSeverity;
readonly exitCode: RunCancellationStatusExitCode;
readonly dispatches: CancellationSummary['dispatches'];
readonly signals: CancellationSummary['signals'];
readonly blockingResults: CancellationSummary['blockingResults'];
readonly oldestBlockedAtMs?: number;
}
export function createRunCancellationStatusCommand(
projectId: string,
createUuid: () => string = randomUUID,
): Readonly<ClusterRunManagementCancellationSummaryCommand> {
const requestId = createUuid();
const auditEventId = createUuid();
let failureAuditEventId = createUuid();
for (
let attempts = 0;
failureAuditEventId === auditEventId && attempts < 3;
attempts += 1
) {
failureAuditEventId = createUuid();
}
return normalizeClusterRunManagementCommand({
schemaVersion: 1,
operation: 'run.cancellation.summary',
request: {
projectId,
requestId,
auditEventId,
failureAuditEventId,
body: { schema: RUN_CANCELLATION_DISPATCH_SUMMARY_REQUEST_SCHEMA },
},
}) as Readonly<ClusterRunManagementCancellationSummaryCommand>;
}
export function projectRunCancellationStatus(
result: Readonly<ClusterRunManagementClientResult>,
): Readonly<RunCancellationStatusObservation> {
if (result.result.operation !== 'run.cancellation.summary') {
throw new TypeError('Run cancellation status requires a summary result');
}
const summary = result.result.summary;
const severity: RunCancellationStatusSeverity =
summary.assessment === 'clear'
? 'ok'
: summary.assessment === 'converging'
? 'warning'
: 'critical';
const exitCode: RunCancellationStatusExitCode =
severity === 'ok' ? 0 : severity === 'warning' ? 10 : 20;
return Object.freeze({
schemaVersion: 1,
schema: RUN_CANCELLATION_STATUS_SCHEMA,
component: 'qinglong3-run-management-client',
event: 'cancellation_status_observed',
requestId: result.requestId,
projectId: summary.projectId,
observedAtMs: summary.observedAtMs,
assessment: summary.assessment,
operatorAction: summary.operatorAction,
severity,
exitCode,
dispatches: summary.dispatches,
signals: summary.signals,
blockingResults: summary.blockingResults,
...(summary.oldestBlockedAtMs === undefined
? {}
: { oldestBlockedAtMs: summary.oldestBlockedAtMs }),
});
}
function label(value: string): string {
return value.replaceAll('_', ' ').toUpperCase();
}
export function formatRunCancellationStatusCard(
status: Readonly<RunCancellationStatusObservation>,
): string {
const dispatch = status.dispatches;
const blocking = status.blockingResults;
return [
'QingLong 3.0 / Cancellation Availability',
`PROJECT ${status.projectId}`,
`ASSESSMENT ${label(status.assessment)}`,
`ACTION ${label(status.operatorAction)}`,
`ALERT ${status.severity.toUpperCase()} (exit ${status.exitCode})`,
`OBSERVED ${new Date(status.observedAtMs).toISOString()}`,
`DISPATCHES total=${dispatch.total} pending=${dispatch.pending} leased=${dispatch.leased} retry_wait=${dispatch.retryWait} dispatched=${dispatch.dispatched} blocked=${dispatch.blocked}`,
`SIGNALS due=${status.signals.due} expired_lease=${status.signals.expiredLease}`,
`BLOCKING identity_mismatch=${blocking.identityMismatch} pid_mismatch=${blocking.pidMismatch} unsupported=${blocking.unsupported} invalid=${blocking.invalid}`,
`OLDEST_BLOCK ${
status.oldestBlockedAtMs === undefined
? '-'
: new Date(status.oldestBlockedAtMs).toISOString()
}`,
`REQUEST ${status.requestId}`,
].join('\n');
}
@@ -14,6 +14,7 @@ import {
import {
ClusterPluginPackageManagementClientRequestError,
executeClusterAuthenticatedManagementClient,
type ClusterAuthenticatedManagementCommandExecution,
type ClusterAuthenticatedManagementClientResult,
type ClusterPluginPackageManagementClientConnectionOptions,
type ClusterPluginPackageManagementClientPaths,
@@ -35,6 +36,8 @@ export type ClusterRunManagementClientConnectionOptions =
ClusterPluginPackageManagementClientConnectionOptions;
export type ClusterRunManagementClientResult =
ClusterAuthenticatedManagementClientResult<ClusterRunManagementTransportResult>;
export type ClusterRunManagementCommandExecution =
ClusterAuthenticatedManagementCommandExecution<ClusterRunManagementCommand>;
function invalid(): never {
throw new ClusterPluginPackageManagementClientRequestError();
@@ -427,3 +430,14 @@ export function executeClusterRunManagementClient(
connectionOptions,
);
}
export function executeClusterRunManagementCommand(
execution: ClusterRunManagementCommandExecution,
connectionOptions?: ClusterRunManagementClientConnectionOptions,
): Promise<Readonly<ClusterRunManagementClientResult>> {
return executeClusterAuthenticatedManagementClient(
execution,
PROTOCOL,
connectionOptions,
);
}
@@ -1,16 +1,73 @@
#!/usr/bin/env node
import { ClusterPluginPackageManagementClientRemoteError } from '../management-support/pluginPackageManagementClient';
import { executeClusterRunManagementClient } from './runManagementClient';
import {
executeClusterRunManagementClient,
executeClusterRunManagementCommand,
} from './runManagementClient';
import {
createRunCancellationStatusCommand,
formatRunCancellationStatusCard,
projectRunCancellationStatus,
} from './runCancellationStatus';
const USAGE =
'Usage: ql3-run-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt';
const USAGE = [
'Usage: ql3-run-client --config=/absolute/client.json --command=/absolute/command.json --assertion=/absolute/assertion.jwt',
' ql3-run-client status --config=/absolute/client.json --assertion=/absolute/assertion.jwt --project=PROJECT [--format=text|json]',
'',
'Status exit codes: 0=clear, 10=converging, 20=attention_required.',
].join('\n');
const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
function argumentsFrom(argv: readonly string[]): Readonly<{
configFile: string;
commandFile: string;
assertionFile: string;
}> | null {
type RunManagementClientArguments =
| Readonly<{
kind: 'command';
configFile: string;
commandFile: string;
assertionFile: string;
}>
| Readonly<{
kind: 'status';
configFile: string;
assertionFile: string;
projectId: string;
format: 'text' | 'json';
}>;
function argumentsFrom(
argv: readonly string[],
): Readonly<RunManagementClientArguments> | null {
const statusCount = argv.filter((argument) => argument === 'status').length;
if (statusCount > 0) {
if (statusCount !== 1 || argv.length < 4 || argv.length > 5) return null;
const values = new Map<string, string>();
for (const argument of argv) {
if (argument === 'status') continue;
const match = /^--(config|assertion|project|format)=(.+)$/.exec(argument);
if (!match || values.has(match[1]!)) return null;
values.set(match[1]!, match[2]!);
}
if (
!values.has('config') ||
!values.get('config')!.startsWith('/') ||
!values.has('assertion') ||
!values.get('assertion')!.startsWith('/') ||
!values.has('project') ||
!PROJECT_ID.test(values.get('project')!) ||
(values.has('format') &&
values.get('format') !== 'text' &&
values.get('format') !== 'json')
) {
return null;
}
return Object.freeze({
kind: 'status',
configFile: values.get('config')!,
assertionFile: values.get('assertion')!,
projectId: values.get('project')!,
format: (values.get('format') ?? 'text') as 'text' | 'json',
});
}
if (argv.length !== 3) return null;
const values = new Map<string, string>();
for (const argument of argv) {
@@ -18,8 +75,14 @@ function argumentsFrom(argv: readonly string[]): Readonly<{
if (!match || values.has(match[1]!)) return null;
values.set(match[1]!, match[2]!);
}
if (!values.has('config') || !values.has('command') || !values.has('assertion')) return null;
if (
!values.has('config') ||
!values.has('command') ||
!values.has('assertion')
)
return null;
return Object.freeze({
kind: 'command',
configFile: values.get('config')!,
commandFile: values.get('command')!,
assertionFile: values.get('assertion')!,
@@ -32,13 +95,18 @@ function failureFact(error: unknown): Readonly<Record<string, unknown>> {
schemaVersion: 1,
component: 'qinglong3-run-management-client',
event: 'command_failed',
code: typeof candidate?.code === 'string' ? candidate.code : 'QL3_RUN_MANAGEMENT_CLIENT_FAILED',
code:
typeof candidate?.code === 'string'
? candidate.code
: 'QL3_RUN_MANAGEMENT_CLIENT_FAILED',
...(error instanceof ClusterPluginPackageManagementClientRemoteError
? {
statusCode: error.statusCode,
responseCode: error.responseCode,
requestId: error.requestId,
...(error.retryAfterSeconds === null ? {} : { retryAfterSeconds: error.retryAfterSeconds }),
...(error.retryAfterSeconds === null
? {}
: { retryAfterSeconds: error.retryAfterSeconds }),
}
: {}),
});
@@ -51,13 +119,43 @@ async function run(argv: readonly string[]): Promise<void> {
}
const paths = argumentsFrom(argv);
if (!paths) {
process.stderr.write(`${JSON.stringify({ schemaVersion: 1, component: 'qinglong3-run-management-client', event: 'usage_invalid', code: 'QL3_RUN_MANAGEMENT_CLIENT_USAGE_INVALID' })}\n`);
process.stderr.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-run-management-client',
event: 'usage_invalid',
code: 'QL3_RUN_MANAGEMENT_CLIENT_USAGE_INVALID',
})}\n`,
);
process.exitCode = 64;
return;
}
try {
if (paths.kind === 'status') {
const result = await executeClusterRunManagementCommand({
configFile: paths.configFile,
assertionFile: paths.assertionFile,
command: createRunCancellationStatusCommand(paths.projectId),
});
const status = projectRunCancellationStatus(result);
process.stdout.write(
paths.format === 'json'
? `${JSON.stringify(status)}\n`
: `${formatRunCancellationStatusCard(status)}\n`,
);
process.exitCode = status.exitCode;
return;
}
const result = await executeClusterRunManagementClient(paths);
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, component: 'qinglong3-run-management-client', event: 'command_completed', requestId: result.requestId, result: result.result })}\n`);
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
component: 'qinglong3-run-management-client',
event: 'command_completed',
requestId: result.requestId,
result: result.result,
})}\n`,
);
} catch (error) {
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
process.exitCode = 1;
@@ -375,7 +375,7 @@ test('catalog exposes only reviewed product entrypoints from the same package',
test('help and version are bounded installation-derived product facts', () => {
const help = qingLong3ClusterProductHelp();
assert.match(help, /^Usage: ql3-cluster-admin <command> \[arguments\]/);
assert.match(help, /\n run\s+retry or stop Runs/);
assert.match(help, /\n run\s+observe, retry or stop Runs/);
assert.match(help, /\n copilot\s+diagnose, inspect, read or cancel Runs/);
assert.match(help, /\n copilot-mcp\s+serve the bounded Cluster Copilot MCP/);
assert.match(help, /\n copilot-console\s+open the loopback-only read-only/);
@@ -0,0 +1,121 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
createRunCancellationStatusCommand,
formatRunCancellationStatusCard,
projectRunCancellationStatus,
} = require('../dist/run-management/runCancellationStatus.js');
const uuids = [
'019f9500-0000-4000-8000-000000000001',
'019f9500-0000-4000-8000-000000000002',
'019f9500-0000-4000-8000-000000000003',
];
function result(assessment) {
const blocked = assessment === 'attention_required' ? 1 : 0;
const pending = assessment === 'converging' ? 1 : 0;
return {
schemaVersion: 1,
requestId: 'request-summary-1',
result: {
schemaVersion: 1,
operation: 'run.cancellation.summary',
summary: {
schema: 'qinglong/run-cancellation-dispatch-summary@v1',
projectId: 'project-1',
observedAtMs: 1_700_000_000_000,
assessment,
operatorAction:
assessment === 'clear'
? 'none'
: assessment === 'converging'
? 'wait'
: 'inspect',
dispatches: {
total: blocked + pending,
pending,
leased: 0,
retryWait: 0,
dispatched: 0,
blocked,
},
signals: { due: pending, expiredLease: 0 },
blockingResults: {
identityMismatch: blocked,
pidMismatch: 0,
unsupported: 0,
invalid: 0,
},
...(blocked === 0 ? {} : { oldestBlockedAtMs: 1_699_999_999_000 }),
},
},
};
}
test('builds one exact Project summary command without a command file', () => {
let index = 0;
assert.deepEqual(
createRunCancellationStatusCommand('project-1', () => uuids[index++]),
{
schemaVersion: 1,
operation: 'run.cancellation.summary',
request: {
projectId: 'project-1',
requestId: uuids[0],
auditEventId: uuids[1],
failureAuditEventId: uuids[2],
body: {
schema: 'qinglong/run-cancellation-dispatch-summary-request@v1',
},
},
},
);
});
test('maps clear, converging and attention assessments to stable alert exits', () => {
const cases = [
['clear', 'ok', 0],
['converging', 'warning', 10],
['attention_required', 'critical', 20],
];
for (const [assessment, severity, exitCode] of cases) {
const status = projectRunCancellationStatus(result(assessment));
assert.equal(status.schema, 'qinglong/run-cancellation-status@v1');
assert.equal(status.assessment, assessment);
assert.equal(status.severity, severity);
assert.equal(status.exitCode, exitCode);
assert.equal(status.projectId, 'project-1');
assert.equal(Object.hasOwn(status, 'oldestBlockedAtMs'), exitCode === 20);
}
});
test('renders a deterministic low-sensitive operator card', () => {
const card = formatRunCancellationStatusCard(
projectRunCancellationStatus(result('attention_required')),
);
assert.match(card, /^QingLong 3\.0 \/ Cancellation Availability\n/);
assert.match(card, /ASSESSMENT ATTENTION REQUIRED/);
assert.match(card, /ALERT CRITICAL \(exit 20\)/);
assert.match(card, /DISPATCHES total=1 .* blocked=1/);
assert.match(card, /BLOCKING identity_mismatch=1/);
assert.doesNotMatch(
card,
/runId|attemptId|leaseOwner|leaseToken|command|environment|secret/i,
);
});
test('refuses to project a non-summary management result', () => {
assert.throws(
() =>
projectRunCancellationStatus({
schemaVersion: 1,
requestId: 'request-1',
result: { schemaVersion: 1, operation: 'run.stop', stop: {} },
}),
/requires a summary result/,
);
});
@@ -15,6 +15,7 @@ const { afterEach, test } = require('node:test');
const {
executeClusterRunManagementClient,
executeClusterRunManagementCommand,
validateClusterRunManagementClientResult,
} = require('@qinglong/cluster-admin/run-management-client');
const {
@@ -217,6 +218,32 @@ test('accepts only the exact Run route before opening one mTLS connection', asyn
assert.equal(connects, 1);
});
test('accepts a normalized in-memory summary command without a command file', async () => {
const paths = clientFiles();
let connects = 0;
await assert.rejects(
executeClusterRunManagementCommand(
{
configFile: paths.configFile,
assertionFile: paths.assertionFile,
command: summaryCommand,
},
{
async connect(target) {
connects += 1;
assert.deepEqual(target, {
hostname: 'run.example.test',
port: 8448,
});
throw new Error('expected-connect-stop');
},
},
),
{ code: 'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_REQUEST_FAILED' },
);
assert.equal(connects, 1);
});
test('rejects response target, execution placement and shape drift', () => {
for (const candidate of [
response({ projectId: 'project-2' }),
@@ -0,0 +1,217 @@
'use strict';
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const { createServer } = require('node:https');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const packageRoot = path.resolve(__dirname, '..');
const cliPath = path.join(
packageRoot,
'dist',
'run-management',
'runManagementClientCli.js',
);
const tlsFixture = path.resolve(
packageRoot,
'../ql3-cluster-control/test/fixtures/mtls',
);
function privateFile(directory, name, contents) {
const filePath = path.join(directory, name);
fs.writeFileSync(filePath, contents, { mode: 0o600 });
return fs.realpathSync(filePath);
}
function runCli(args) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cliPath, ...args], {
cwd: packageRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = [];
const stderr = [];
child.stdout.on('data', (chunk) => stdout.push(chunk));
child.stderr.on('data', (chunk) => stderr.push(chunk));
child.once('error', reject);
child.once('close', (status, signal) => {
resolve({
status,
signal,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
});
});
});
}
test('status mode calls only the summary operation and emits an alert exit', async (t) => {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-run-status-cli-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const caFile = privateFile(
directory,
'ca.pem',
fs.readFileSync(path.join(tlsFixture, 'ca-cert.pem')),
);
const clientCertificateFile = privateFile(
directory,
'client.crt',
fs.readFileSync(path.join(tlsFixture, 'client-cert.pem')),
);
const clientPrivateKeyFile = privateFile(
directory,
'client.key',
fs.readFileSync(path.join(tlsFixture, 'client-key.pem')),
);
const requests = [];
const server = createServer(
{
key: fs.readFileSync(path.join(tlsFixture, 'server-key.pem')),
cert: fs.readFileSync(path.join(tlsFixture, 'server-cert.pem')),
ca: fs.readFileSync(path.join(tlsFixture, 'ca-cert.pem')),
requestCert: true,
rejectUnauthorized: true,
minVersion: 'TLSv1.3',
maxVersion: 'TLSv1.3',
},
(request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
const command = JSON.parse(Buffer.concat(chunks).toString('utf8'));
requests.push({
method: request.method,
path: request.url,
authorization: request.headers.authorization,
authorized: request.socket.authorized,
command,
});
const bytes = Buffer.from(
JSON.stringify({
schemaVersion: 1,
requestId: command.request.requestId,
result: {
schemaVersion: 1,
operation: 'run.cancellation.summary',
summary: {
schema: 'qinglong/run-cancellation-dispatch-summary@v1',
projectId: 'project-1',
observedAtMs: 1_700_000_000_000,
assessment: 'attention_required',
operatorAction: 'inspect',
dispatches: {
total: 1,
pending: 0,
leased: 0,
retryWait: 0,
dispatched: 0,
blocked: 1,
},
signals: { due: 0, expiredLease: 0 },
blockingResults: {
identityMismatch: 1,
pidMismatch: 0,
unsupported: 0,
invalid: 0,
},
oldestBlockedAtMs: 1_699_999_999_000,
},
},
}),
'utf8',
);
response.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'content-length': String(bytes.byteLength),
});
response.end(bytes);
});
},
);
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(
() =>
new Promise((resolve) => {
server.close(() => resolve());
}),
);
const address = server.address();
assert.notEqual(address, null);
assert.notEqual(typeof address, 'string');
const configFile = privateFile(
directory,
'client.json',
JSON.stringify({
schemaVersion: 1,
endpoint: `https://localhost:${address.port}/api/v3/runs/management`,
servername: 'localhost',
caFile,
clientCertificateFile,
clientPrivateKeyFile,
requestTimeoutMs: 2_000,
}),
);
const assertionFile = privateFile(
directory,
'assertion.jwt',
'eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJvcGVyYXRvci0xIn0.c2lnbmF0dXJl',
);
const result = await runCli([
`--config=${configFile}`,
'status',
`--assertion=${assertionFile}`,
'--project=project-1',
'--format=json',
]);
assert.equal(result.status, 20, result.stderr);
assert.equal(result.signal, null);
assert.equal(result.stderr, '');
const output = JSON.parse(result.stdout);
assert.equal(output.schema, 'qinglong/run-cancellation-status@v1');
assert.equal(output.assessment, 'attention_required');
assert.equal(output.severity, 'critical');
assert.equal(output.exitCode, 20);
assert.equal(output.dispatches.blocked, 1);
assert.equal(requests.length, 1);
assert.equal(requests[0].method, 'POST');
assert.equal(requests[0].path, '/api/v3/runs/management');
assert.equal(requests[0].authorized, true);
assert.match(requests[0].authorization, /^Bearer [A-Za-z0-9_-]+\./);
assert.equal(requests[0].command.operation, 'run.cancellation.summary');
assert.deepEqual(requests[0].command.request.body, {
schema: 'qinglong/run-cancellation-dispatch-summary-request@v1',
});
assert.equal(Object.hasOwn(requests[0].command.request, 'runId'), false);
});
test('help documents status routing and invalid projects fail before I/O', async () => {
const help = await runCli(['--help']);
assert.equal(help.status, 0);
assert.match(help.stdout, /ql3-run-client status/);
assert.match(help.stdout, /0=clear, 10=converging, 20=attention_required/);
const rejected = await runCli([
'status',
'--config=/private/client.json',
'--assertion=/private/assertion.jwt',
'--project=../escape',
]);
assert.equal(rejected.status, 64);
assert.equal(rejected.stdout, '');
assert.deepEqual(JSON.parse(rejected.stderr), {
schemaVersion: 1,
component: 'qinglong3-run-management-client',
event: 'usage_invalid',
code: 'QL3_RUN_MANAGEMENT_CLIENT_USAGE_INVALID',
});
});