@kb-labs/gateway-app 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.kb/database/kb.sqlite-shm +0 -0
- package/.kb/database/kb.sqlite-wal +0 -0
- package/package.json +49 -0
- package/src/__tests__/auth-routes.test.ts +279 -0
- package/src/__tests__/execute-routes.test.ts +408 -0
- package/src/__tests__/execution-registry.test.ts +218 -0
- package/src/__tests__/health.test.ts +215 -0
- package/src/__tests__/live-gateway.e2e.test.ts +648 -0
- package/src/__tests__/llm-gateway.test.ts +361 -0
- package/src/__tests__/observability-collector.test.ts +59 -0
- package/src/__tests__/platform-api.test.ts +317 -0
- package/src/__tests__/registry.test.ts +546 -0
- package/src/__tests__/retry-executor.test.ts +244 -0
- package/src/__tests__/server.integration.test.ts +417 -0
- package/src/__tests__/subscription-registry.test.ts +308 -0
- package/src/__tests__/telemetry-ingest.test.ts +309 -0
- package/src/__tests__/tokens.test.ts +83 -0
- package/src/__tests__/ws-client-connect.e2e.test.ts +381 -0
- package/src/__tests__/ws-handshake.e2e.test.ts +288 -0
- package/src/auth/middleware.ts +50 -0
- package/src/auth/routes.ts +57 -0
- package/src/auth/tokens.ts +41 -0
- package/src/bootstrap.ts +98 -0
- package/src/clients/subscription-registry.ts +137 -0
- package/src/clients/ws-handler.ts +196 -0
- package/src/config.ts +20 -0
- package/src/docs/routes.ts +70 -0
- package/src/execute/errors.ts +21 -0
- package/src/execute/execution-registry.ts +84 -0
- package/src/execute/retry-executor.ts +159 -0
- package/src/execute/routes.ts +239 -0
- package/src/hosts/dispatcher.ts +2 -0
- package/src/hosts/registry.ts +305 -0
- package/src/hosts/ws-handler.ts +445 -0
- package/src/index.ts +7 -0
- package/src/llm/routes.ts +343 -0
- package/src/manifest.ts +21 -0
- package/src/observability/collector.ts +346 -0
- package/src/platform/routes.ts +195 -0
- package/src/server.ts +447 -0
- package/src/telemetry/routes.ts +89 -0
- package/src/ws/gateway-ws.ts +73 -0
- package/tsconfig.build.json +15 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +8 -0
- package/vitest.config.ts +23 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module gateway-app/execute/errors
|
|
3
|
+
*
|
|
4
|
+
* Shared error types for execution pipeline.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CancellationReason } from '@kb-labs/core-contracts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thrown when an execution is cancelled (CC2).
|
|
11
|
+
* Caught by routes.ts to emit execution:cancelled + execution:done(130).
|
|
12
|
+
*/
|
|
13
|
+
export class CancelledError extends Error {
|
|
14
|
+
readonly reason: CancellationReason;
|
|
15
|
+
|
|
16
|
+
constructor(reason: CancellationReason) {
|
|
17
|
+
super(`Execution cancelled: ${reason}`);
|
|
18
|
+
this.name = 'CancelledError';
|
|
19
|
+
this.reason = reason;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module gateway-app/execute/execution-registry
|
|
3
|
+
*
|
|
4
|
+
* Tracks active executions with AbortController for cancellation.
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle:
|
|
7
|
+
* 1. POST /execute → registry.register(executionId, ...) → AbortSignal
|
|
8
|
+
* 2. POST /execute/:id/cancel → registry.cancel(executionId, reason) → abort()
|
|
9
|
+
* 3. Execution completes or aborted → registry.remove(executionId)
|
|
10
|
+
*
|
|
11
|
+
* Also supports host disconnect: cancelByHost() aborts all executions
|
|
12
|
+
* dispatched to a host that went offline.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CancellationReason } from '@kb-labs/core-contracts';
|
|
16
|
+
|
|
17
|
+
export interface ActiveExecution {
|
|
18
|
+
executionId: string;
|
|
19
|
+
requestId: string;
|
|
20
|
+
namespaceId: string;
|
|
21
|
+
hostId: string;
|
|
22
|
+
pluginId: string;
|
|
23
|
+
handlerRef: string;
|
|
24
|
+
controller: AbortController;
|
|
25
|
+
startedAt: number;
|
|
26
|
+
cancelledReason?: CancellationReason;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ExecutionRegistry {
|
|
30
|
+
private executions = new Map<string, ActiveExecution>();
|
|
31
|
+
|
|
32
|
+
/** Register a new execution. Returns AbortSignal to wire into the dispatch. */
|
|
33
|
+
register(entry: Omit<ActiveExecution, 'controller' | 'startedAt'>): AbortSignal {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
this.executions.set(entry.executionId, {
|
|
36
|
+
...entry,
|
|
37
|
+
controller,
|
|
38
|
+
startedAt: Date.now(),
|
|
39
|
+
});
|
|
40
|
+
return controller.signal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cancel an execution by ID. Returns true if found and aborted. */
|
|
44
|
+
cancel(executionId: string, reason: CancellationReason): boolean {
|
|
45
|
+
const entry = this.executions.get(executionId);
|
|
46
|
+
if (!entry) { return false; }
|
|
47
|
+
if (entry.controller.signal.aborted) { return false; }
|
|
48
|
+
|
|
49
|
+
entry.cancelledReason = reason;
|
|
50
|
+
entry.controller.abort(reason);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Remove a completed/cancelled execution. */
|
|
55
|
+
remove(executionId: string): void {
|
|
56
|
+
this.executions.delete(executionId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get an active execution. */
|
|
60
|
+
get(executionId: string): ActiveExecution | undefined {
|
|
61
|
+
return this.executions.get(executionId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Cancel all executions dispatched to a host (on host disconnect). */
|
|
65
|
+
cancelByHost(hostId: string, reason: CancellationReason): string[] {
|
|
66
|
+
const cancelled: string[] = [];
|
|
67
|
+
for (const entry of this.executions.values()) {
|
|
68
|
+
if (entry.hostId === hostId && !entry.controller.signal.aborted) {
|
|
69
|
+
entry.cancelledReason = reason;
|
|
70
|
+
entry.controller.abort(reason);
|
|
71
|
+
cancelled.push(entry.executionId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return cancelled;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Number of active executions. */
|
|
78
|
+
get size(): number {
|
|
79
|
+
return this.executions.size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Singleton — shared across Gateway process. */
|
|
84
|
+
export const executionRegistry = new ExecutionRegistry();
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module gateway-app/execute/retry-executor
|
|
3
|
+
*
|
|
4
|
+
* Level 2 retry wrapper for execution dispatch (CC3).
|
|
5
|
+
*
|
|
6
|
+
* Wraps a dispatch function with configurable retry + exponential backoff.
|
|
7
|
+
* Emits execution:retry events between attempts so clients see progress.
|
|
8
|
+
* Respects AbortSignal — cancels immediately, no retry after abort.
|
|
9
|
+
*
|
|
10
|
+
* Each attempt is raced against the AbortSignal so that a cancelled
|
|
11
|
+
* execution doesn't block on a hung dispatcher call.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExecutionRetryConfig, CancellationReason } from '@kb-labs/core-contracts';
|
|
15
|
+
import type { ExecutionEventMessage } from '@kb-labs/gateway-contracts';
|
|
16
|
+
import { CancelledError } from './errors.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULTS: Required<ExecutionRetryConfig> = {
|
|
19
|
+
maxAttempts: 1,
|
|
20
|
+
initialDelayMs: 1000,
|
|
21
|
+
backoffMultiplier: 2,
|
|
22
|
+
maxDelayMs: 30_000,
|
|
23
|
+
onlyRetryable: true,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface RetryContext {
|
|
27
|
+
executionId: string;
|
|
28
|
+
requestId: string;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
config: ExecutionRetryConfig | undefined;
|
|
31
|
+
/** Emit event to client stream (and WS subscribers via routes.ts). */
|
|
32
|
+
write: (event: ExecutionEventMessage) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Execute dispatch function with retry + abort-race logic.
|
|
37
|
+
*
|
|
38
|
+
* - maxAttempts=1 (default) → single call, raced against signal.
|
|
39
|
+
* - maxAttempts>1 → retry loop with backoff, each attempt raced against signal.
|
|
40
|
+
*/
|
|
41
|
+
export async function executeWithRetry<T>(
|
|
42
|
+
ctx: RetryContext,
|
|
43
|
+
dispatch: () => Promise<T>,
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const cfg = { ...DEFAULTS, ...ctx.config };
|
|
46
|
+
const maxAttempts = Math.max(1, cfg.maxAttempts);
|
|
47
|
+
|
|
48
|
+
let lastError: Error | null = null;
|
|
49
|
+
|
|
50
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
51
|
+
if (ctx.signal.aborted) {
|
|
52
|
+
throw new CancelledError(ctx.signal.reason as CancellationReason);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
return await raceAbort(ctx.signal, dispatch());
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Cancellation is never retried
|
|
59
|
+
if (err instanceof CancelledError) { throw err; }
|
|
60
|
+
|
|
61
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
62
|
+
|
|
63
|
+
// Last attempt — propagate error
|
|
64
|
+
if (attempt >= maxAttempts) { break; }
|
|
65
|
+
|
|
66
|
+
// Check if error is retryable
|
|
67
|
+
const classified = classifyError(err);
|
|
68
|
+
if (cfg.onlyRetryable && !classified.retryable) { break; }
|
|
69
|
+
|
|
70
|
+
// Backoff delay
|
|
71
|
+
const delay = Math.min(
|
|
72
|
+
cfg.initialDelayMs * Math.pow(cfg.backoffMultiplier, attempt - 1),
|
|
73
|
+
cfg.maxDelayMs,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
ctx.write({
|
|
77
|
+
type: 'execution:retry',
|
|
78
|
+
requestId: ctx.requestId,
|
|
79
|
+
executionId: ctx.executionId,
|
|
80
|
+
attempt,
|
|
81
|
+
maxAttempts,
|
|
82
|
+
delayMs: delay,
|
|
83
|
+
error: classified.message,
|
|
84
|
+
} satisfies ExecutionEventMessage);
|
|
85
|
+
|
|
86
|
+
await interruptibleDelay(delay, ctx.signal);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
throw lastError ?? new Error('executeWithRetry: no attempts made');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Internals ──
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Race a promise against an AbortSignal.
|
|
97
|
+
*/
|
|
98
|
+
function raceAbort<T>(signal: AbortSignal, promise: Promise<T>): Promise<T> {
|
|
99
|
+
if (signal.aborted) {
|
|
100
|
+
return Promise.reject(new CancelledError(signal.reason as CancellationReason));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return new Promise<T>((resolve, reject) => {
|
|
104
|
+
const onAbort = () => reject(new CancelledError(signal.reason as CancellationReason));
|
|
105
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
106
|
+
|
|
107
|
+
promise.then(
|
|
108
|
+
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
|
|
109
|
+
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sleep interruptible by AbortSignal.
|
|
116
|
+
*/
|
|
117
|
+
function interruptibleDelay(ms: number, signal: AbortSignal): Promise<void> {
|
|
118
|
+
if (signal.aborted) {
|
|
119
|
+
return Promise.reject(new CancelledError(signal.reason as CancellationReason));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return new Promise<void>((resolve, reject) => {
|
|
123
|
+
const timer = setTimeout(resolve, ms);
|
|
124
|
+
const onAbort = () => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
reject(new CancelledError(signal.reason as CancellationReason));
|
|
127
|
+
};
|
|
128
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ClassifiedError {
|
|
133
|
+
code: string;
|
|
134
|
+
message: string;
|
|
135
|
+
retryable: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function classifyError(err: unknown): ClassifiedError {
|
|
139
|
+
if (!(err instanceof Error)) {
|
|
140
|
+
return { code: 'UNKNOWN', message: String(err), retryable: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const msg = err.message;
|
|
144
|
+
|
|
145
|
+
// Transport / network — retryable
|
|
146
|
+
if (msg.includes('ECONNREFUSED') || msg.includes('ECONNRESET') ||
|
|
147
|
+
msg.includes('ETIMEDOUT') || msg.includes('timed out') ||
|
|
148
|
+
msg.includes('503') || msg.includes('Service Unavailable')) {
|
|
149
|
+
return { code: 'TRANSPORT_ERROR', message: msg, retryable: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Host went offline — retryable (may reconnect)
|
|
153
|
+
if (msg.includes('Host not connected')) {
|
|
154
|
+
return { code: 'HOST_UNAVAILABLE', message: msg, retryable: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Everything else — not retryable
|
|
158
|
+
return { code: 'HANDLER_ERROR', message: msg, retryable: false };
|
|
159
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module gateway-app/execute/routes
|
|
3
|
+
*
|
|
4
|
+
* POST /api/v1/execute — Execute handler via Gateway with ndjson streaming.
|
|
5
|
+
* POST /api/v1/execute/:executionId/cancel — Cancel an active execution.
|
|
6
|
+
*
|
|
7
|
+
* Response: Transfer-Encoding: chunked, Content-Type: application/x-ndjson
|
|
8
|
+
* Each line = JSON ExecutionEvent.
|
|
9
|
+
*
|
|
10
|
+
* Integrates:
|
|
11
|
+
* CC2 — Cancellation via ExecutionRegistry + AbortController
|
|
12
|
+
* CC3 — Retry via executeWithRetry (exponential backoff, retryable errors)
|
|
13
|
+
* CC5 — Broadcast to WS subscribers via SubscriptionRegistry
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { ExecuteRequestSchema, type ExecutionEventMessage } from '@kb-labs/gateway-contracts';
|
|
19
|
+
import { logDiagnosticEvent, type ILogger } from '@kb-labs/core-platform';
|
|
20
|
+
import type { CancellationReason } from '@kb-labs/core-contracts';
|
|
21
|
+
import { globalDispatcher } from '../hosts/dispatcher.js';
|
|
22
|
+
import { executionRegistry } from './execution-registry.js';
|
|
23
|
+
import { subscriptionRegistry } from '../clients/subscription-registry.js';
|
|
24
|
+
import { executeWithRetry } from './retry-executor.js';
|
|
25
|
+
import { CancelledError } from './errors.js';
|
|
26
|
+
|
|
27
|
+
export function registerExecuteRoutes(app: FastifyInstance, logger: ILogger): void {
|
|
28
|
+
/**
|
|
29
|
+
* POST /api/v1/execute
|
|
30
|
+
* hide: true — uses ndjson chunked streaming via reply.raw, incompatible with OpenAPI response schema
|
|
31
|
+
*/
|
|
32
|
+
app.post('/api/v1/execute', { schema: { tags: ['Execute'], summary: 'Execute a plugin handler', hide: true } }, async (request: FastifyRequest, reply: FastifyReply) => {
|
|
33
|
+
const auth = request.authContext;
|
|
34
|
+
if (!auth) {
|
|
35
|
+
return reply.code(401).send({ error: 'Unauthorized' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const parsed = ExecuteRequestSchema.safeParse(request.body);
|
|
39
|
+
if (!parsed.success) {
|
|
40
|
+
return reply.code(400).send({ error: 'Bad Request', issues: parsed.error.issues });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { pluginId, handlerRef, exportName, input, timeoutMs } = parsed.data;
|
|
44
|
+
const executionId = randomUUID();
|
|
45
|
+
const requestId = randomUUID();
|
|
46
|
+
const startTime = Date.now();
|
|
47
|
+
|
|
48
|
+
logger.info('Execute request received', {
|
|
49
|
+
executionId,
|
|
50
|
+
pluginId,
|
|
51
|
+
handlerRef,
|
|
52
|
+
namespaceId: auth.namespaceId,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Resolve host with 'execution' capability
|
|
56
|
+
const hostId = globalDispatcher.firstHostWithCapability(auth.namespaceId, 'execution');
|
|
57
|
+
if (!hostId) {
|
|
58
|
+
logDiagnosticEvent(logger, {
|
|
59
|
+
domain: 'service',
|
|
60
|
+
event: 'gateway.execution.dispatch',
|
|
61
|
+
level: 'warn',
|
|
62
|
+
reasonCode: 'execution_host_unavailable',
|
|
63
|
+
message: 'No execution host connected for namespace',
|
|
64
|
+
outcome: 'failed',
|
|
65
|
+
serviceId: 'gateway',
|
|
66
|
+
evidence: {
|
|
67
|
+
namespaceId: auth.namespaceId,
|
|
68
|
+
pluginId,
|
|
69
|
+
handlerRef,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
return reply.code(503).send({
|
|
73
|
+
error: 'No execution host connected',
|
|
74
|
+
hint: 'Ensure a RuntimeServer is running and connected to Gateway',
|
|
75
|
+
namespaceId: auth.namespaceId,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Register in execution registry (CC2)
|
|
80
|
+
const signal = executionRegistry.register({
|
|
81
|
+
executionId,
|
|
82
|
+
requestId,
|
|
83
|
+
namespaceId: auth.namespaceId,
|
|
84
|
+
hostId,
|
|
85
|
+
pluginId,
|
|
86
|
+
handlerRef,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Auto-cancel on client disconnect.
|
|
90
|
+
// reply.raw 'close' fires when connection drops before response finishes.
|
|
91
|
+
// Check writableFinished to distinguish mid-stream disconnect from normal completion.
|
|
92
|
+
reply.raw.on('close', () => {
|
|
93
|
+
if (!reply.raw.writableFinished && !signal.aborted) {
|
|
94
|
+
executionRegistry.cancel(executionId, 'disconnect');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Hijack response for ndjson streaming
|
|
99
|
+
reply.raw.writeHead(200, {
|
|
100
|
+
'Content-Type': 'application/x-ndjson',
|
|
101
|
+
'Transfer-Encoding': 'chunked',
|
|
102
|
+
'Cache-Control': 'no-cache',
|
|
103
|
+
'X-Execution-Id': executionId,
|
|
104
|
+
});
|
|
105
|
+
// Flush headers immediately so the client can see X-Execution-Id before any events
|
|
106
|
+
reply.raw.flushHeaders();
|
|
107
|
+
|
|
108
|
+
// Write typed event to initiator ndjson stream + broadcast to WS subscribers (CC5)
|
|
109
|
+
const writeEvent = (event: ExecutionEventMessage): void => {
|
|
110
|
+
const payload = JSON.stringify(event) + '\n';
|
|
111
|
+
if (!reply.raw.writableEnded) {
|
|
112
|
+
reply.raw.write(payload);
|
|
113
|
+
}
|
|
114
|
+
subscriptionRegistry.broadcast(executionId, event);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// Dispatch with retry + abort-race (CC2 + CC3)
|
|
119
|
+
// TODO: read retry config from platform config when available
|
|
120
|
+
const result = await executeWithRetry(
|
|
121
|
+
{ executionId, requestId, signal, config: undefined, write: writeEvent },
|
|
122
|
+
() => globalDispatcher.call(
|
|
123
|
+
auth.namespaceId,
|
|
124
|
+
hostId,
|
|
125
|
+
'execution',
|
|
126
|
+
'execute',
|
|
127
|
+
[{ pluginId, handlerRef, exportName, input, executionId, requestId, timeoutMs }],
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
writeEvent({
|
|
132
|
+
type: 'execution:done',
|
|
133
|
+
requestId,
|
|
134
|
+
executionId,
|
|
135
|
+
exitCode: 0,
|
|
136
|
+
durationMs: Date.now() - startTime,
|
|
137
|
+
metadata: { result: result as Record<string, unknown> },
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err instanceof CancelledError) {
|
|
141
|
+
writeEvent({
|
|
142
|
+
type: 'execution:cancelled',
|
|
143
|
+
requestId,
|
|
144
|
+
executionId,
|
|
145
|
+
reason: err.reason ?? 'user',
|
|
146
|
+
durationMs: Date.now() - startTime,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
writeEvent({
|
|
150
|
+
type: 'execution:done',
|
|
151
|
+
requestId,
|
|
152
|
+
executionId,
|
|
153
|
+
exitCode: 130,
|
|
154
|
+
durationMs: Date.now() - startTime,
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
logDiagnosticEvent(logger, {
|
|
159
|
+
domain: 'service',
|
|
160
|
+
event: 'gateway.execution.dispatch',
|
|
161
|
+
level: 'error',
|
|
162
|
+
reasonCode: 'execution_dispatch_failed',
|
|
163
|
+
message: 'Gateway execution dispatch failed',
|
|
164
|
+
outcome: 'failed',
|
|
165
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
166
|
+
serviceId: 'gateway',
|
|
167
|
+
evidence: {
|
|
168
|
+
namespaceId: auth.namespaceId,
|
|
169
|
+
executionId,
|
|
170
|
+
requestId,
|
|
171
|
+
pluginId,
|
|
172
|
+
handlerRef,
|
|
173
|
+
hostId,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
writeEvent({
|
|
178
|
+
type: 'execution:error',
|
|
179
|
+
requestId,
|
|
180
|
+
executionId,
|
|
181
|
+
code: 'EXECUTION_FAILED',
|
|
182
|
+
message,
|
|
183
|
+
retryable: false,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
writeEvent({
|
|
187
|
+
type: 'execution:done',
|
|
188
|
+
requestId,
|
|
189
|
+
executionId,
|
|
190
|
+
exitCode: 1,
|
|
191
|
+
durationMs: Date.now() - startTime,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
executionRegistry.remove(executionId);
|
|
196
|
+
if (!reply.raw.writableEnded) {
|
|
197
|
+
reply.raw.end();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* POST /api/v1/execute/:executionId/cancel
|
|
204
|
+
*/
|
|
205
|
+
app.post('/api/v1/execute/:executionId/cancel', { schema: { tags: ['Execute'], summary: 'Cancel an active execution' } }, async (request: FastifyRequest, reply: FastifyReply) => {
|
|
206
|
+
const auth = request.authContext;
|
|
207
|
+
if (!auth) {
|
|
208
|
+
return reply.code(401).send({ error: 'Unauthorized' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { executionId } = request.params as { executionId: string };
|
|
212
|
+
const body = request.body as { reason?: string } | undefined;
|
|
213
|
+
const reason = (body?.reason ?? 'user') as CancellationReason;
|
|
214
|
+
|
|
215
|
+
const execution = executionRegistry.get(executionId);
|
|
216
|
+
if (!execution) {
|
|
217
|
+
return reply.code(404).send({ error: 'Execution not found or already completed' });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (execution.namespaceId !== auth.namespaceId) {
|
|
221
|
+
return reply.code(403).send({ error: 'Forbidden — execution belongs to another namespace' });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const cancelled = executionRegistry.cancel(executionId, reason);
|
|
225
|
+
|
|
226
|
+
logger.info('Cancel request processed', {
|
|
227
|
+
executionId,
|
|
228
|
+
reason,
|
|
229
|
+
cancelled,
|
|
230
|
+
namespaceId: auth.namespaceId,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return reply.code(cancelled ? 200 : 409).send({
|
|
234
|
+
executionId,
|
|
235
|
+
status: cancelled ? 'cancelled' : 'already_cancelled',
|
|
236
|
+
reason,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|