@ismaelsoilet/synapsejs 1.3.0 → 1.8.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/README.md +15 -8
- package/bin/synapse.ts +70 -3
- package/package.json +1 -1
- package/src/client/components.tsx +26 -9
- package/src/client/outlet.tsx +40 -0
- package/src/client/sub-slice.tsx +45 -0
- package/src/client/use-websocket.ts +146 -0
- package/src/client.ts +12 -0
- package/src/compiler/client-bundler.ts +17 -7
- package/src/compiler/migration-runner.ts +134 -9
- package/src/compiler/scaffolder.ts +62 -0
- package/src/compiler/schema-dag.ts +18 -11
- package/src/compiler/slice-splitter.ts +29 -4
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +56 -0
- package/src/core/action-definition.ts +89 -0
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +37 -1
- package/src/core/index.ts +3 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +27 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +7 -1
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +1 -5
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +8 -1
- package/src/mcp/server.ts +63 -1
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +180 -0
- package/src/runtime/queue-engine.ts +55 -21
- package/src/runtime/rate-limiter.ts +14 -0
- package/src/runtime/server.ts +572 -139
- package/src/version.ts +4 -0
- package/templates/starter/.jev.json +1 -1
package/src/mcp/server.ts
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
type AbortGateResult,
|
|
11
|
+
type ModelRouteResult,
|
|
11
12
|
modulateReasoningEffort,
|
|
13
|
+
type NudgeGateResult,
|
|
12
14
|
type ReasoningEffortResult,
|
|
15
|
+
routeModelTier,
|
|
13
16
|
shouldAbortTrajectory,
|
|
17
|
+
shouldNudgeContinuation,
|
|
14
18
|
type TestTriageResult,
|
|
15
19
|
triageTestFailure,
|
|
16
20
|
type VerificationResult,
|
|
@@ -28,6 +32,7 @@ import { type SliceTemplate, scaffoldCrud, scaffoldSlice } from '../compiler/sca
|
|
|
28
32
|
import { checkSchemaDrift } from '../compiler/schema-drift';
|
|
29
33
|
import { findSliceFiles, resolveSlicesDir } from '../compiler/slice-discovery';
|
|
30
34
|
import { artifactDirectory, splitSlice, verifySplit, writeSplitArtifacts } from '../compiler/slice-splitter';
|
|
35
|
+
import { SYNAPSE_VERSION } from '../version';
|
|
31
36
|
|
|
32
37
|
interface JsonRpcRequest {
|
|
33
38
|
jsonrpc: string;
|
|
@@ -90,7 +95,7 @@ export class SynapseMcpServer {
|
|
|
90
95
|
},
|
|
91
96
|
serverInfo: {
|
|
92
97
|
name: 'synapse-mcp',
|
|
93
|
-
version:
|
|
98
|
+
version: SYNAPSE_VERSION
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
};
|
|
@@ -272,6 +277,51 @@ export class SynapseMcpServer {
|
|
|
272
277
|
}
|
|
273
278
|
}
|
|
274
279
|
}
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'synapse_triage_error',
|
|
283
|
+
description:
|
|
284
|
+
'Triage an arbitrary test failure or execution error log using Jev System One to classify root causes (env_missing vs flaky_transient vs deep_logic) and avoid token waste',
|
|
285
|
+
inputSchema: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
required: ['failureLog'],
|
|
288
|
+
properties: {
|
|
289
|
+
failureLog: {
|
|
290
|
+
type: 'string',
|
|
291
|
+
description: 'Error log, stack trace or terminal output to triage'
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'synapse_route_task',
|
|
298
|
+
description:
|
|
299
|
+
'Semantically route a development task to the optimal model/reasoning tier via Jev System One',
|
|
300
|
+
inputSchema: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
required: ['task'],
|
|
303
|
+
properties: {
|
|
304
|
+
task: {
|
|
305
|
+
type: 'string',
|
|
306
|
+
description: 'Description of the coding, refactoring, or diagnostic task'
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'synapse_evaluate_nudge',
|
|
313
|
+
description:
|
|
314
|
+
'Evaluate whether an agent is prematurely stopping or needs a proactive nudge to continue verification',
|
|
315
|
+
inputSchema: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
required: ['transcriptTail'],
|
|
318
|
+
properties: {
|
|
319
|
+
transcriptTail: {
|
|
320
|
+
type: 'string',
|
|
321
|
+
description: 'Recent transcript steps or conversation history tail'
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
275
325
|
}
|
|
276
326
|
]
|
|
277
327
|
}
|
|
@@ -597,6 +647,18 @@ export class SynapseMcpServer {
|
|
|
597
647
|
sessionContextTokens
|
|
598
648
|
});
|
|
599
649
|
contentText = JSON.stringify(result, null, 2);
|
|
650
|
+
} else if (toolName === 'synapse_triage_error') {
|
|
651
|
+
const failureLog = String(args.failureLog || '');
|
|
652
|
+
const result: TestTriageResult = await triageTestFailure(failureLog);
|
|
653
|
+
contentText = JSON.stringify(result, null, 2);
|
|
654
|
+
} else if (toolName === 'synapse_route_task') {
|
|
655
|
+
const task = String(args.task || '');
|
|
656
|
+
const result: ModelRouteResult = await routeModelTier(task);
|
|
657
|
+
contentText = JSON.stringify(result, null, 2);
|
|
658
|
+
} else if (toolName === 'synapse_evaluate_nudge') {
|
|
659
|
+
const transcriptTail = String(args.transcriptTail || '');
|
|
660
|
+
const result: NudgeGateResult = await shouldNudgeContinuation(transcriptTail);
|
|
661
|
+
contentText = JSON.stringify(result, null, 2);
|
|
600
662
|
} else {
|
|
601
663
|
return {
|
|
602
664
|
jsonrpc: '2.0',
|
|
@@ -13,7 +13,11 @@ export const WEBHOOK_SUFFIX = 'Webhook';
|
|
|
13
13
|
export const LOADER_SUFFIX = 'Loader';
|
|
14
14
|
export const SCHEMA_SUFFIX = 'Schema';
|
|
15
15
|
export const META_SUFFIX = 'Meta';
|
|
16
|
+
export const CACHE_SUFFIX = 'Cache';
|
|
17
|
+
export const SOCKET_SUFFIX = 'Socket';
|
|
16
18
|
export const SLICE_META_EXPORT = 'sliceMeta';
|
|
19
|
+
export const SLICE_CACHE_EXPORT = 'sliceCache';
|
|
20
|
+
export const SLICE_SOCKET_EXPORT = 'sliceSocket';
|
|
17
21
|
export const SLICE_DDL_EXPORT = 'sliceSchema';
|
|
18
22
|
export const SLICE_ORACLE_EXPORT = 'sliceTests';
|
|
19
23
|
|
|
@@ -21,7 +25,14 @@ export const SLICE_ORACLE_EXPORT = 'sliceTests';
|
|
|
21
25
|
export const COMPONENT_SUFFIXES = ['Trigger', 'View', 'Form', 'Component'] as const;
|
|
22
26
|
|
|
23
27
|
/** Everything the server side of a slice is built from. */
|
|
24
|
-
export const SERVER_ROOT_SUFFIXES = [
|
|
28
|
+
export const SERVER_ROOT_SUFFIXES = [
|
|
29
|
+
ACTION_SUFFIX,
|
|
30
|
+
JOB_SUFFIX,
|
|
31
|
+
WEBHOOK_SUFFIX,
|
|
32
|
+
META_SUFFIX,
|
|
33
|
+
CACHE_SUFFIX,
|
|
34
|
+
SOCKET_SUFFIX
|
|
35
|
+
] as const;
|
|
25
36
|
|
|
26
37
|
/** Test-only exports, dropped from both runtime bundles. */
|
|
27
38
|
export const TEST_ONLY_EXPORTS = [SLICE_ORACLE_EXPORT] as const;
|
|
@@ -58,6 +69,14 @@ export function isMeta(name: string): boolean {
|
|
|
58
69
|
return name === SLICE_META_EXPORT || name.endsWith(META_SUFFIX);
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
export function isCache(name: string): boolean {
|
|
73
|
+
return name === SLICE_CACHE_EXPORT || name.endsWith(CACHE_SUFFIX);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isSocket(name: string): boolean {
|
|
77
|
+
return name === SLICE_SOCKET_EXPORT || name.endsWith(SOCKET_SUFFIX);
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
export function isTestOnly(name: string): boolean {
|
|
62
81
|
return hasSuffix(name, TEST_ONLY_EXPORTS);
|
|
63
82
|
}
|
package/src/runtime/event-hub.ts
CHANGED
|
@@ -46,7 +46,12 @@ export class EventHub {
|
|
|
46
46
|
let notified = 0;
|
|
47
47
|
for (const listener of listeners) {
|
|
48
48
|
try {
|
|
49
|
-
listener(data);
|
|
49
|
+
const result: unknown = listener(data);
|
|
50
|
+
if (result && typeof (result as { catch?: unknown }).catch === 'function') {
|
|
51
|
+
(result as { catch: (fn: (err: unknown) => void) => void }).catch((err: unknown) => {
|
|
52
|
+
console.error(`[EventHub] Erro assíncrono no listener do tópico "${topic}":`, err);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
50
55
|
notified++;
|
|
51
56
|
} catch (err) {
|
|
52
57
|
console.error(`[EventHub] Erro no listener do tópico "${topic}":`, err);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import dns from 'node:dns';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks whether an IP address (IPv4 or IPv6) belongs to a private, loopback,
|
|
6
|
+
* link-local, or cloud-provider metadata address space.
|
|
7
|
+
*/
|
|
8
|
+
export function isPrivateOrReservedIp(ip: string): boolean {
|
|
9
|
+
const version = net.isIP(ip);
|
|
10
|
+
if (version === 0) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (version === 4) {
|
|
15
|
+
const parts = ip.split('.').map((p) => parseInt(p, 10));
|
|
16
|
+
if (parts.length !== 4 || parts.some(Number.isNaN)) return true;
|
|
17
|
+
|
|
18
|
+
const [a, b] = parts;
|
|
19
|
+
|
|
20
|
+
// 0.0.0.0/8 (Broadcast/Current network)
|
|
21
|
+
if (a === 0) return true;
|
|
22
|
+
|
|
23
|
+
// 10.0.0.0/8 (Private)
|
|
24
|
+
if (a === 10) return true;
|
|
25
|
+
|
|
26
|
+
// 100.64.0.0/10 (Carrier-grade NAT)
|
|
27
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
28
|
+
|
|
29
|
+
// 127.0.0.0/8 (Loopback)
|
|
30
|
+
if (a === 127) return true;
|
|
31
|
+
|
|
32
|
+
// 169.254.0.0/16 (Link-local & Cloud Metadata: AWS, GCP, Azure, DO)
|
|
33
|
+
if (a === 169 && b === 254) return true;
|
|
34
|
+
|
|
35
|
+
// 172.16.0.0/12 (Private)
|
|
36
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
37
|
+
|
|
38
|
+
// 192.0.0.0/24 (IETF Protocol Assignments)
|
|
39
|
+
// 192.0.2.0/24 (TEST-NET-1)
|
|
40
|
+
if (a === 192 && b === 0) return true;
|
|
41
|
+
|
|
42
|
+
// 192.168.0.0/16 (Private)
|
|
43
|
+
if (a === 192 && b === 168) return true;
|
|
44
|
+
|
|
45
|
+
// 198.18.0.0/15 (Benchmarking)
|
|
46
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
47
|
+
|
|
48
|
+
// 198.51.100.0/24 (TEST-NET-2)
|
|
49
|
+
if (a === 198 && b === 51) return true;
|
|
50
|
+
|
|
51
|
+
// 203.0.113.0/24 (TEST-NET-3)
|
|
52
|
+
if (a === 203 && b === 0) return true;
|
|
53
|
+
|
|
54
|
+
// 224.0.0.0/4 (Multicast) & 240.0.0.0/4 (Reserved)
|
|
55
|
+
if (a >= 224) return true;
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// IPv6 checks
|
|
61
|
+
const normalized = ip.toLowerCase();
|
|
62
|
+
|
|
63
|
+
// ::1 (Loopback)
|
|
64
|
+
if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true;
|
|
65
|
+
|
|
66
|
+
// :: (Unspecified)
|
|
67
|
+
if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
|
|
68
|
+
|
|
69
|
+
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
|
70
|
+
if (normalized.startsWith('::ffff:')) {
|
|
71
|
+
const v4Part = normalized.slice(7);
|
|
72
|
+
if (net.isIPv4(v4Part)) {
|
|
73
|
+
return isPrivateOrReservedIp(v4Part);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// fc00::/7 (Unique Local Address ULA)
|
|
78
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
|
79
|
+
|
|
80
|
+
// fe80::/10 (Link-Local Unicast)
|
|
81
|
+
if (/^fe[89ab]/.test(normalized)) return true;
|
|
82
|
+
|
|
83
|
+
// ff00::/8 (Multicast)
|
|
84
|
+
if (normalized.startsWith('ff')) return true;
|
|
85
|
+
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ValidateUrlOptions {
|
|
90
|
+
allowedDomains?: string[];
|
|
91
|
+
allowLocalhost?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ValidateUrlResult {
|
|
95
|
+
ok: boolean;
|
|
96
|
+
error?: string;
|
|
97
|
+
resolvedIp?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validates a remote URL against SSRF vulnerabilities:
|
|
102
|
+
* - Scheme must be http or https
|
|
103
|
+
* - Hostname cannot be localhost or known cloud metadata domains (unless allowLocalhost is true)
|
|
104
|
+
* - Domain must match allowedDomains list if specified
|
|
105
|
+
* - Pre-resolves DNS to ensure target IP is not private, loopback, or cloud metadata
|
|
106
|
+
*/
|
|
107
|
+
export async function validateExternalUrl(
|
|
108
|
+
rawUrl: string,
|
|
109
|
+
options: ValidateUrlOptions = {}
|
|
110
|
+
): Promise<ValidateUrlResult> {
|
|
111
|
+
let parsed: URL;
|
|
112
|
+
try {
|
|
113
|
+
parsed = new URL(rawUrl);
|
|
114
|
+
} catch {
|
|
115
|
+
return { ok: false, error: 'INVALID_URL' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
119
|
+
return { ok: false, error: 'INVALID_PROTOCOL' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
123
|
+
|
|
124
|
+
// Disallow user/password in URL for SSRF protection
|
|
125
|
+
if (parsed.username || parsed.password) {
|
|
126
|
+
return { ok: false, error: 'CREDENTIALS_IN_URL_FORBIDDEN' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Common metadata and internal hosts
|
|
130
|
+
if (
|
|
131
|
+
hostname === 'metadata.google.internal' ||
|
|
132
|
+
hostname === 'instance-data' ||
|
|
133
|
+
hostname.endsWith('.internal') ||
|
|
134
|
+
hostname.endsWith('.local')
|
|
135
|
+
) {
|
|
136
|
+
return { ok: false, error: 'FORBIDDEN_METADATA_HOST' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!options.allowLocalhost && (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1')) {
|
|
140
|
+
return { ok: false, error: 'LOOPBACK_FORBIDDEN' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Allowed domains enforcement
|
|
144
|
+
if (options.allowedDomains && options.allowedDomains.length > 0) {
|
|
145
|
+
const isAllowed = options.allowedDomains.some((d) => {
|
|
146
|
+
const dom = d.toLowerCase().trim();
|
|
147
|
+
return hostname === dom || hostname.endsWith(`.${dom}`);
|
|
148
|
+
});
|
|
149
|
+
if (!isAllowed) {
|
|
150
|
+
return { ok: false, error: 'DOMAIN_NOT_ALLOWED' };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// If hostname is directly an IP literal
|
|
155
|
+
if (net.isIP(hostname)) {
|
|
156
|
+
if (!options.allowLocalhost && isPrivateOrReservedIp(hostname)) {
|
|
157
|
+
return { ok: false, error: 'FORBIDDEN_TARGET_IP', resolvedIp: hostname };
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, resolvedIp: hostname };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// DNS Pre-check to prevent DNS rebinding
|
|
163
|
+
try {
|
|
164
|
+
const records = await dns.promises.lookup(hostname, { all: true });
|
|
165
|
+
if (!records || records.length === 0) {
|
|
166
|
+
return { ok: false, error: 'DNS_LOOKUP_EMPTY' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const record of records) {
|
|
170
|
+
if (!options.allowLocalhost && isPrivateOrReservedIp(record.address)) {
|
|
171
|
+
return { ok: false, error: 'FORBIDDEN_TARGET_IP', resolvedIp: record.address };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { ok: true, resolvedIp: records[0].address };
|
|
176
|
+
} catch (err: unknown) {
|
|
177
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
178
|
+
return { ok: false, error: `DNS_LOOKUP_FAILED: ${message}` };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -31,6 +31,7 @@ export interface JobRecord {
|
|
|
31
31
|
run_at: number;
|
|
32
32
|
created_at: number;
|
|
33
33
|
updated_at: number;
|
|
34
|
+
locked_at?: number | null;
|
|
34
35
|
last_error: string | null;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -38,15 +39,23 @@ export interface QueueEngineOptions {
|
|
|
38
39
|
/** Path to SQLite database file or ':memory:' */
|
|
39
40
|
dbPath?: string;
|
|
40
41
|
logger?: StructuredLogger;
|
|
42
|
+
/** Inactivity window in ms before a 'running' job without heartbeat is recovered (default: 300_000ms = 5m) */
|
|
43
|
+
visibilityTimeoutMs?: number;
|
|
44
|
+
/** Inactivity window in seconds (alternative to visibilityTimeoutMs) */
|
|
45
|
+
visibilityTimeoutSeconds?: number;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export class QueueEngine {
|
|
44
49
|
private db: Database;
|
|
45
50
|
private registry: Map<string, JobDefinition<unknown>> = new Map();
|
|
46
51
|
private logger: StructuredLogger;
|
|
52
|
+
private visibilityTimeoutMs: number;
|
|
47
53
|
|
|
48
54
|
constructor(options: QueueEngineOptions = {}) {
|
|
49
55
|
this.logger = options.logger || createDefaultLogger('QueueEngine');
|
|
56
|
+
this.visibilityTimeoutMs =
|
|
57
|
+
options.visibilityTimeoutMs ??
|
|
58
|
+
(options.visibilityTimeoutSeconds !== undefined ? options.visibilityTimeoutSeconds * 1000 : 300_000);
|
|
50
59
|
const dbPath = options.dbPath || path.join(process.cwd(), '.synapse/queue.sqlite');
|
|
51
60
|
|
|
52
61
|
if (dbPath !== ':memory:') {
|
|
@@ -80,11 +89,22 @@ export class QueueEngine {
|
|
|
80
89
|
run_at INTEGER NOT NULL,
|
|
81
90
|
created_at INTEGER NOT NULL,
|
|
82
91
|
updated_at INTEGER NOT NULL,
|
|
92
|
+
locked_at INTEGER,
|
|
83
93
|
last_error TEXT
|
|
84
94
|
);
|
|
85
95
|
CREATE INDEX IF NOT EXISTS idx_synapse_jobs_pending
|
|
86
96
|
ON _synapse_jobs (status, run_at, priority DESC);
|
|
87
97
|
`);
|
|
98
|
+
try {
|
|
99
|
+
this.db.run('ALTER TABLE _synapse_jobs ADD COLUMN locked_at INTEGER;');
|
|
100
|
+
} catch {
|
|
101
|
+
// Column already exists
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
this.db.run('CREATE INDEX IF NOT EXISTS idx_synapse_jobs_stale ON _synapse_jobs (status, locked_at);');
|
|
105
|
+
} catch {
|
|
106
|
+
// Index already exists
|
|
107
|
+
}
|
|
88
108
|
}
|
|
89
109
|
|
|
90
110
|
/**
|
|
@@ -123,23 +143,37 @@ export class QueueEngine {
|
|
|
123
143
|
}
|
|
124
144
|
|
|
125
145
|
/**
|
|
126
|
-
* Atomically claims the next pending job eligible for execution
|
|
146
|
+
* Atomically claims the next pending job eligible for execution, or recovers
|
|
147
|
+
* any stale 'running' job whose worker has died (visibility timeout exceeded).
|
|
127
148
|
*/
|
|
128
|
-
claimNextJob(): JobRecord | null {
|
|
149
|
+
claimNextJob(visibilityTimeoutMs?: number): JobRecord | null {
|
|
129
150
|
const now = Date.now();
|
|
151
|
+
const timeout = visibilityTimeoutMs ?? this.visibilityTimeoutMs;
|
|
152
|
+
const staleThreshold = now - timeout;
|
|
153
|
+
|
|
154
|
+
// Fail expired zombie jobs that already exhausted max_attempts
|
|
155
|
+
this.db
|
|
156
|
+
.prepare(`
|
|
157
|
+
UPDATE _synapse_jobs
|
|
158
|
+
SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = 'Visibility timeout exceeded max attempts'
|
|
159
|
+
WHERE status = 'running' AND (locked_at IS NULL OR locked_at < ?2) AND attempts >= max_attempts
|
|
160
|
+
`)
|
|
161
|
+
.run(now, staleThreshold);
|
|
162
|
+
|
|
130
163
|
const stmt = this.db.prepare(`
|
|
131
164
|
UPDATE _synapse_jobs
|
|
132
|
-
SET status = 'running', updated_at = ?1
|
|
165
|
+
SET status = 'running', locked_at = ?1, updated_at = ?1, attempts = attempts + 1
|
|
133
166
|
WHERE id = (
|
|
134
167
|
SELECT id FROM _synapse_jobs
|
|
135
|
-
WHERE status = 'pending' AND run_at <= ?1
|
|
168
|
+
WHERE (status = 'pending' AND run_at <= ?1)
|
|
169
|
+
OR (status = 'running' AND (locked_at IS NULL OR locked_at < ?2) AND attempts < max_attempts)
|
|
136
170
|
ORDER BY priority DESC, run_at ASC, created_at ASC
|
|
137
171
|
LIMIT 1
|
|
138
172
|
)
|
|
139
173
|
RETURNING *;
|
|
140
174
|
`);
|
|
141
175
|
|
|
142
|
-
const row = stmt.get(now) as JobRecord | undefined;
|
|
176
|
+
const row = stmt.get(now, staleThreshold) as JobRecord | undefined;
|
|
143
177
|
return row || null;
|
|
144
178
|
}
|
|
145
179
|
|
|
@@ -158,7 +192,7 @@ export class QueueEngine {
|
|
|
158
192
|
}
|
|
159
193
|
|
|
160
194
|
const jobDef = this.registry.get(job.name);
|
|
161
|
-
const attempts = job.attempts
|
|
195
|
+
const attempts = job.attempts;
|
|
162
196
|
const now = Date.now();
|
|
163
197
|
|
|
164
198
|
if (!jobDef) {
|
|
@@ -166,10 +200,10 @@ export class QueueEngine {
|
|
|
166
200
|
this.logger.error(errorMsg);
|
|
167
201
|
const updateStmt = this.db.prepare(`
|
|
168
202
|
UPDATE _synapse_jobs
|
|
169
|
-
SET status = 'failed',
|
|
170
|
-
WHERE id = ?4
|
|
203
|
+
SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
|
|
204
|
+
WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
|
|
171
205
|
`);
|
|
172
|
-
updateStmt.run(
|
|
206
|
+
updateStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
|
|
173
207
|
return true;
|
|
174
208
|
}
|
|
175
209
|
|
|
@@ -180,10 +214,10 @@ export class QueueEngine {
|
|
|
180
214
|
const errorMsg = `Falha ao desserializar payload JSON do job: ${String(parseErr)}`;
|
|
181
215
|
const updateStmt = this.db.prepare(`
|
|
182
216
|
UPDATE _synapse_jobs
|
|
183
|
-
SET status = 'failed',
|
|
184
|
-
WHERE id = ?4
|
|
217
|
+
SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
|
|
218
|
+
WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
|
|
185
219
|
`);
|
|
186
|
-
updateStmt.run(
|
|
220
|
+
updateStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
|
|
187
221
|
return true;
|
|
188
222
|
}
|
|
189
223
|
|
|
@@ -199,10 +233,10 @@ export class QueueEngine {
|
|
|
199
233
|
await jobDef.handler(parsedPayload, actionCtx);
|
|
200
234
|
const completeStmt = this.db.prepare(`
|
|
201
235
|
UPDATE _synapse_jobs
|
|
202
|
-
SET status = 'completed',
|
|
203
|
-
WHERE id = ?3
|
|
236
|
+
SET status = 'completed', locked_at = NULL, updated_at = ?1, last_error = NULL
|
|
237
|
+
WHERE id = ?2 AND (locked_at = ?3 OR locked_at IS NULL)
|
|
204
238
|
`);
|
|
205
|
-
completeStmt.run(
|
|
239
|
+
completeStmt.run(now, job.id, job.locked_at ?? null);
|
|
206
240
|
return true;
|
|
207
241
|
} catch (handlerErr: unknown) {
|
|
208
242
|
const errorMsg = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
|
|
@@ -214,17 +248,17 @@ export class QueueEngine {
|
|
|
214
248
|
const nextRunAt = now + backoffMs;
|
|
215
249
|
const retryStmt = this.db.prepare(`
|
|
216
250
|
UPDATE _synapse_jobs
|
|
217
|
-
SET status = 'pending',
|
|
218
|
-
WHERE id = ?5
|
|
251
|
+
SET status = 'pending', locked_at = NULL, run_at = ?1, updated_at = ?2, last_error = ?3
|
|
252
|
+
WHERE id = ?4 AND (locked_at = ?5 OR locked_at IS NULL)
|
|
219
253
|
`);
|
|
220
|
-
retryStmt.run(
|
|
254
|
+
retryStmt.run(nextRunAt, now, errorMsg, job.id, job.locked_at ?? null);
|
|
221
255
|
} else {
|
|
222
256
|
const failStmt = this.db.prepare(`
|
|
223
257
|
UPDATE _synapse_jobs
|
|
224
|
-
SET status = 'failed',
|
|
225
|
-
WHERE id = ?4
|
|
258
|
+
SET status = 'failed', locked_at = NULL, updated_at = ?1, last_error = ?2
|
|
259
|
+
WHERE id = ?3 AND (locked_at = ?4 OR locked_at IS NULL)
|
|
226
260
|
`);
|
|
227
|
-
failStmt.run(
|
|
261
|
+
failStmt.run(now, errorMsg, job.id, job.locked_at ?? null);
|
|
228
262
|
}
|
|
229
263
|
return true;
|
|
230
264
|
}
|
|
@@ -18,6 +18,8 @@ export interface RateLimitOptions {
|
|
|
18
18
|
refillRate?: number;
|
|
19
19
|
/** Inactivity window in ms before bucket is evicted (default: 60,000ms = 1m) */
|
|
20
20
|
idleTimeoutMs?: number;
|
|
21
|
+
/** Maximum tracked buckets in memory before LRU eviction (default: 10,000) */
|
|
22
|
+
maxBuckets?: number;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface RateLimitResult {
|
|
@@ -37,6 +39,7 @@ export class TokenBucketRateLimiter {
|
|
|
37
39
|
private capacity: number;
|
|
38
40
|
private refillRate: number;
|
|
39
41
|
private idleTimeoutMs: number;
|
|
42
|
+
private maxBuckets: number;
|
|
40
43
|
private buckets: Map<string, Bucket> = new Map();
|
|
41
44
|
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
42
45
|
|
|
@@ -47,6 +50,7 @@ export class TokenBucketRateLimiter {
|
|
|
47
50
|
this.refillRate = options.refillRate ?? (Number.isFinite(envRps) && envRps > 0 ? envRps : 100);
|
|
48
51
|
this.capacity = options.capacity ?? (Number.isFinite(envBurst) && envBurst > 0 ? envBurst : 150);
|
|
49
52
|
this.idleTimeoutMs = options.idleTimeoutMs ?? 60_000;
|
|
53
|
+
this.maxBuckets = options.maxBuckets ?? 10_000;
|
|
50
54
|
|
|
51
55
|
// Run background prune of idle buckets every minute
|
|
52
56
|
this.cleanupTimer = setInterval(() => this.pruneIdleBuckets(), this.idleTimeoutMs);
|
|
@@ -63,12 +67,22 @@ export class TokenBucketRateLimiter {
|
|
|
63
67
|
let bucket = this.buckets.get(key);
|
|
64
68
|
|
|
65
69
|
if (!bucket) {
|
|
70
|
+
if (this.buckets.size >= this.maxBuckets) {
|
|
71
|
+
const oldest = this.buckets.keys().next().value;
|
|
72
|
+
if (oldest !== undefined) {
|
|
73
|
+
this.buckets.delete(oldest);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
66
76
|
bucket = {
|
|
67
77
|
tokens: this.capacity,
|
|
68
78
|
lastRefill: now,
|
|
69
79
|
lastAccess: now
|
|
70
80
|
};
|
|
71
81
|
this.buckets.set(key, bucket);
|
|
82
|
+
} else {
|
|
83
|
+
// Move to end to track recency (LRU)
|
|
84
|
+
this.buckets.delete(key);
|
|
85
|
+
this.buckets.set(key, bucket);
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
// Refill tokens based on elapsed time
|