deepdebug-local-agent 1.0.22 → 1.0.23
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 +81 -0
- package/docs/legacy-connectors.md +268 -0
- package/package.json +4 -1
- package/scripts/sync-fixtures.mjs +246 -0
- package/src/adapters/as400/as400-parser.js +12 -0
- package/src/adapters/as400/index.js +134 -0
- package/src/adapters/cobol/cobol-parser.js +37 -0
- package/src/adapters/cobol/index.js +151 -0
- package/src/adapters/ibmmq/ibmmq-parser.js +23 -0
- package/src/adapters/ibmmq/index.js +137 -0
- package/src/adapters/index.js +79 -0
- package/src/adapters/oracle-forms/index.js +145 -0
- package/src/adapters/oracle-forms/oracle-forms-parser.js +11 -0
- package/src/adapters/sap/index.js +146 -0
- package/src/adapters/sap/sap-parser.js +41 -0
- package/src/server.js +46 -2
- package/test/adapters/as400/as400.test.js +121 -0
- package/test/adapters/cobol/cobol.test.js +217 -0
- package/test/adapters/ibmmq/ibmmq.test.js +122 -0
- package/test/adapters/oracle-forms/oracle-forms.test.js +180 -0
- package/test/adapters/registry.test.js +101 -0
- package/test/adapters/sap/sap.test.js +142 -0
- package/src/local-agent-source.tar.gz +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/adapters/oracle-forms/index.js
|
|
2
|
+
// OracleFormsLogAdapter — watches *.trc files in the configured Forms trace
|
|
3
|
+
// dir; emits 6-key error events. AD-1 lifecycle, AD-3 polling, AD-4 byte-position
|
|
4
|
+
// dedup with TAIL-on-startup default, AD-5 per-file ordering, AD-6 Latin-1
|
|
5
|
+
// mojibake-trap tolerance via latin1 decode (never throws on any byte).
|
|
6
|
+
// T-2-01 mitigation: default polling interval 5000 ms.
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { stat, open } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import chokidar from 'chokidar';
|
|
12
|
+
import { extractFormsCode } from './oracle-forms-parser.js';
|
|
13
|
+
|
|
14
|
+
const ADAPTER = 'oracle-forms';
|
|
15
|
+
|
|
16
|
+
// Decode buffer using Latin-1 (single-byte) encoding — never throws on any
|
|
17
|
+
// byte sequence. ORA/FRM message codes are ASCII so the regex match is
|
|
18
|
+
// unaffected by the choice; accented free-text payload bytes survive into
|
|
19
|
+
// `message` as Latin-1 characters (mojibake-tolerant per AD-6 Forms trap).
|
|
20
|
+
function decodeTolerant(buf) {
|
|
21
|
+
return buf.toString('latin1');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class OracleFormsLogAdapter extends EventEmitter {
|
|
25
|
+
constructor(config = {}) {
|
|
26
|
+
super();
|
|
27
|
+
this.traceDir = config.traceDir || '/u01/oracle/forms/trace';
|
|
28
|
+
this.interval = config.interval ?? 5000;
|
|
29
|
+
this.replayOnStart = config.replayOnStart === true;
|
|
30
|
+
this._watcher = null;
|
|
31
|
+
this._offsets = new Map(); // filePath → byte offset
|
|
32
|
+
this._inodes = new Map(); // filePath → inode
|
|
33
|
+
this._isRunning = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get isRunning() {
|
|
37
|
+
return this._isRunning;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async start() {
|
|
41
|
+
if (this._isRunning) return;
|
|
42
|
+
this._isRunning = true;
|
|
43
|
+
|
|
44
|
+
// Glob bound to *.trc only (wildcard — chokidar polling-mode bracket-class
|
|
45
|
+
// workaround from Plan 02-08 does NOT apply here; *.trc is a plain wildcard).
|
|
46
|
+
const glob = join(this.traceDir, '*.trc');
|
|
47
|
+
|
|
48
|
+
this._watcher = chokidar.watch(glob, {
|
|
49
|
+
usePolling: true,
|
|
50
|
+
interval: this.interval,
|
|
51
|
+
ignoreInitial: false, // we handle initial ourselves via TAIL seek
|
|
52
|
+
awaitWriteFinish: false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const onAdd = async (filePath) => {
|
|
56
|
+
if (this.replayOnStart) {
|
|
57
|
+
this._offsets.set(filePath, 0);
|
|
58
|
+
// fall through to _readNew below
|
|
59
|
+
} else {
|
|
60
|
+
try {
|
|
61
|
+
const st = await stat(filePath);
|
|
62
|
+
this._offsets.set(filePath, st.size);
|
|
63
|
+
this._inodes.set(filePath, st.ino);
|
|
64
|
+
return; // TAIL mode — don't emit pre-existing content
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
this._readNew(filePath).catch(() => { /* ENOENT silence */ });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
this._watcher.on('add', onAdd);
|
|
73
|
+
this._watcher.on('change', (fp) => {
|
|
74
|
+
this._readNew(fp).catch(() => { /* ENOENT silence */ });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Swallow chokidar's own bubbled errors (e.g., watch-path ENOENT) —
|
|
78
|
+
// dual-layer suppression with the per-read try/catch ensures full
|
|
79
|
+
// ENOENT silence (canonical pattern from Plan 02-07).
|
|
80
|
+
this._watcher.on('error', () => {});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async stop() {
|
|
84
|
+
if (!this._isRunning) return;
|
|
85
|
+
this._isRunning = false;
|
|
86
|
+
if (this._watcher) {
|
|
87
|
+
await this._watcher.close().catch(() => {});
|
|
88
|
+
this._watcher = null;
|
|
89
|
+
}
|
|
90
|
+
this._offsets.clear();
|
|
91
|
+
this._inodes.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async _readNew(filePath) {
|
|
95
|
+
let st;
|
|
96
|
+
try {
|
|
97
|
+
st = await stat(filePath);
|
|
98
|
+
} catch {
|
|
99
|
+
return; // ENOENT silence
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lastOffset = this._offsets.get(filePath) ?? 0;
|
|
103
|
+
const lastInode = this._inodes.get(filePath);
|
|
104
|
+
|
|
105
|
+
// Rotation: inode changed OR size shrunk → reset to 0
|
|
106
|
+
if ((lastInode !== undefined && st.ino !== lastInode) || st.size < lastOffset) {
|
|
107
|
+
this._offsets.set(filePath, 0);
|
|
108
|
+
this._inodes.set(filePath, st.ino);
|
|
109
|
+
return this._readNew(filePath); // re-enter with offset=0
|
|
110
|
+
}
|
|
111
|
+
this._inodes.set(filePath, st.ino);
|
|
112
|
+
|
|
113
|
+
if (st.size <= lastOffset) return;
|
|
114
|
+
|
|
115
|
+
const fh = await open(filePath, 'r');
|
|
116
|
+
try {
|
|
117
|
+
const len = st.size - lastOffset;
|
|
118
|
+
const buf = Buffer.alloc(len);
|
|
119
|
+
await fh.read(buf, 0, len, lastOffset);
|
|
120
|
+
this._offsets.set(filePath, st.size);
|
|
121
|
+
const text = decodeTolerant(buf);
|
|
122
|
+
const lines = text.split(/\r?\n/);
|
|
123
|
+
const sourceDir = filePath.split('/').slice(0, -1).join('/');
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (line.trim().length === 0) continue;
|
|
126
|
+
const code = extractFormsCode(line);
|
|
127
|
+
if (!code) continue;
|
|
128
|
+
// 6-key frozen event payload (AD-4 contract — extra keys forbidden)
|
|
129
|
+
const event = Object.freeze({
|
|
130
|
+
adapter: ADAPTER,
|
|
131
|
+
errorCode: code,
|
|
132
|
+
file: filePath,
|
|
133
|
+
message: line,
|
|
134
|
+
source: sourceDir,
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
});
|
|
137
|
+
this.emit('error', event);
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
await fh.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default OracleFormsLogAdapter;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/adapters/oracle-forms/oracle-forms-parser.js
|
|
2
|
+
// T-2-03 isolation surface: regex exported as a named const so it can be
|
|
3
|
+
// unit-tested against arbitrary text decoupled from the file-watching
|
|
4
|
+
// machinery in index.js. The watcher itself is bound to a literal *.trc
|
|
5
|
+
// glob so this regex never engages arbitrary text by construction.
|
|
6
|
+
export const ORACLE_FORMS_REGEX = /(?:Line \d+:|^)\s*(ORA-\d{5}|FRM-\d{5}):\s*(.+)/;
|
|
7
|
+
|
|
8
|
+
export function extractFormsCode(line) {
|
|
9
|
+
const m = line.match(ORACLE_FORMS_REGEX);
|
|
10
|
+
return m ? m[1] : null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// src/adapters/sap/index.js
|
|
2
|
+
// SapLogAdapter — watches dev_w* and SLOG* files; emits 6-key error events.
|
|
3
|
+
// AD-1 lifecycle, AD-3 polling, AD-4 byte-position dedup, AD-5 per-file ordering.
|
|
4
|
+
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { stat, open } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import chokidar from 'chokidar';
|
|
9
|
+
import { SAP_DEV_W_REGEX, SAP_SLOG_REGEX, normalizeDevWMatch, normalizeSlogMatch } from './sap-parser.js';
|
|
10
|
+
|
|
11
|
+
const ADAPTER = 'sap';
|
|
12
|
+
|
|
13
|
+
export class SapLogAdapter extends EventEmitter {
|
|
14
|
+
constructor(config = {}) {
|
|
15
|
+
super();
|
|
16
|
+
this.sapDir = config.sapDir || '/usr/sap/PRD';
|
|
17
|
+
this.instanceNumber = config.instanceNumber || '00';
|
|
18
|
+
this.interval = config.interval ?? 5000;
|
|
19
|
+
this.replayOnStart = config.replayOnStart === true;
|
|
20
|
+
this._watchers = [];
|
|
21
|
+
this._offsets = new Map(); // filePath → byte offset
|
|
22
|
+
this._inodes = new Map(); // filePath → inode
|
|
23
|
+
this._isRunning = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get isRunning() { return this._isRunning; }
|
|
27
|
+
|
|
28
|
+
async start() {
|
|
29
|
+
if (this._isRunning) return;
|
|
30
|
+
this._isRunning = true;
|
|
31
|
+
|
|
32
|
+
const workGlob = join(this.sapDir, `D${this.instanceNumber}`, 'work', 'dev_w*');
|
|
33
|
+
const logGlob = join(this.sapDir, `D${this.instanceNumber}`, 'log', 'SLOG*');
|
|
34
|
+
|
|
35
|
+
const watcherOpts = {
|
|
36
|
+
usePolling: true,
|
|
37
|
+
interval: this.interval,
|
|
38
|
+
ignoreInitial: false, // we handle initial ourselves via TAIL seek
|
|
39
|
+
awaitWriteFinish: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const wWork = chokidar.watch(workGlob, watcherOpts);
|
|
43
|
+
const wSlog = chokidar.watch(logGlob, watcherOpts);
|
|
44
|
+
|
|
45
|
+
const handle = (filePath, kind) => {
|
|
46
|
+
// ENOENT-tolerant: stat() inside _readNew handles disappearance.
|
|
47
|
+
this._readNew(filePath, kind).catch(() => { /* swallow per ENOENT silence rule */ });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 'add' fires for files present at watch start; honor TAIL/replay flag.
|
|
51
|
+
const onAdd = (kind) => async (filePath) => {
|
|
52
|
+
if (this.replayOnStart) {
|
|
53
|
+
this._offsets.set(filePath, 0);
|
|
54
|
+
} else {
|
|
55
|
+
try {
|
|
56
|
+
const st = await stat(filePath);
|
|
57
|
+
this._offsets.set(filePath, st.size);
|
|
58
|
+
this._inodes.set(filePath, st.ino);
|
|
59
|
+
return; // TAIL mode — don't emit pre-existing content
|
|
60
|
+
} catch { return; }
|
|
61
|
+
}
|
|
62
|
+
handle(filePath, kind);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
wWork.on('add', onAdd('dev_w'));
|
|
66
|
+
wWork.on('change', (fp) => handle(fp, 'dev_w'));
|
|
67
|
+
wSlog.on('add', onAdd('slog'));
|
|
68
|
+
wSlog.on('change', (fp) => handle(fp, 'slog'));
|
|
69
|
+
|
|
70
|
+
// chokidar's 'error' events would otherwise bubble; swallow to keep ENOENT silent.
|
|
71
|
+
wWork.on('error', () => {});
|
|
72
|
+
wSlog.on('error', () => {});
|
|
73
|
+
|
|
74
|
+
this._watchers.push(wWork, wSlog);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async stop() {
|
|
78
|
+
if (!this._isRunning) return;
|
|
79
|
+
this._isRunning = false;
|
|
80
|
+
await Promise.all(this._watchers.map((w) => w.close().catch(() => {})));
|
|
81
|
+
this._watchers = [];
|
|
82
|
+
this._offsets.clear();
|
|
83
|
+
this._inodes.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async _readNew(filePath, kind) {
|
|
87
|
+
let st;
|
|
88
|
+
try { st = await stat(filePath); } catch { return; } // ENOENT silence
|
|
89
|
+
|
|
90
|
+
const lastOffset = this._offsets.get(filePath) ?? 0;
|
|
91
|
+
const lastInode = this._inodes.get(filePath);
|
|
92
|
+
|
|
93
|
+
// Rotation: inode changed OR size shrunk → reset to 0
|
|
94
|
+
if ((lastInode !== undefined && st.ino !== lastInode) || st.size < lastOffset) {
|
|
95
|
+
this._offsets.set(filePath, 0);
|
|
96
|
+
this._inodes.set(filePath, st.ino);
|
|
97
|
+
return this._readNew(filePath, kind); // re-enter with offset=0
|
|
98
|
+
}
|
|
99
|
+
this._inodes.set(filePath, st.ino);
|
|
100
|
+
|
|
101
|
+
if (st.size <= lastOffset) return;
|
|
102
|
+
|
|
103
|
+
const fh = await open(filePath, 'r');
|
|
104
|
+
try {
|
|
105
|
+
const len = st.size - lastOffset;
|
|
106
|
+
const buf = Buffer.alloc(len);
|
|
107
|
+
await fh.read(buf, 0, len, lastOffset);
|
|
108
|
+
this._offsets.set(filePath, st.size);
|
|
109
|
+
// Tolerate CRLF and LF; split, drop trailing empty (no event for blank lines per AD-6 SAP empty-line trap).
|
|
110
|
+
const text = buf.toString('utf8');
|
|
111
|
+
const lines = text.split(/\r?\n/);
|
|
112
|
+
const sourceDir = filePath.split('/').slice(0, -1).join('/');
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (line.trim().length === 0) continue;
|
|
115
|
+
this._tryEmit(line, filePath, sourceDir, kind);
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
await fh.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_tryEmit(line, filePath, sourceDir, kind) {
|
|
123
|
+
let errorCode = null;
|
|
124
|
+
if (kind === 'dev_w') {
|
|
125
|
+
const m = line.match(SAP_DEV_W_REGEX);
|
|
126
|
+
errorCode = normalizeDevWMatch(m);
|
|
127
|
+
} else if (kind === 'slog') {
|
|
128
|
+
const m = line.match(SAP_SLOG_REGEX);
|
|
129
|
+
errorCode = normalizeSlogMatch(m);
|
|
130
|
+
}
|
|
131
|
+
if (errorCode === null) return; // no recognizable code → no event
|
|
132
|
+
|
|
133
|
+
// 6-key frozen event payload (AD-4 contract — extra keys forbidden)
|
|
134
|
+
const event = Object.freeze({
|
|
135
|
+
adapter: ADAPTER,
|
|
136
|
+
errorCode,
|
|
137
|
+
file: filePath,
|
|
138
|
+
message: line,
|
|
139
|
+
source: sourceDir,
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
});
|
|
142
|
+
this.emit('error', event);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default SapLogAdapter;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/adapters/sap/sap-parser.js
|
|
2
|
+
// T-2-03 mitigation: regexes exported as named constants for isolated testing.
|
|
3
|
+
// Adapter binds chokidar to dev_w* and SLOG* file globs only; these regexes
|
|
4
|
+
// never run against arbitrary text by the adapter itself.
|
|
5
|
+
|
|
6
|
+
// Matches ABAP work-process trace error lines:
|
|
7
|
+
// "M *** ERROR => Runtime error CONVT_NO_NUMBER"
|
|
8
|
+
// "M *** ERROR => Exception condition CX_SY_OPEN_SQL_DB raised"
|
|
9
|
+
// "B *** ERROR => SQL error in SELECT, sy-subrc = 4"
|
|
10
|
+
export const SAP_DEV_W_REGEX = /\*\*\* ERROR =>\s+(?:Runtime error|Exception condition|SQL error in [A-Z]+,)\s*([A-Z_]+(?:[A-Z_0-9]*)|sy-subrc\s*=\s*\d+)/i;
|
|
11
|
+
|
|
12
|
+
// Matches SLOG pipe-delimited error/fatal records:
|
|
13
|
+
// "| 2024-01-15 09:12:44 | E | 04 | Database error..., sy-subrc = 4 |"
|
|
14
|
+
// "| 2024-01-15 14:01:55 | F | 22 | Short dump 4815162343 created |"
|
|
15
|
+
export const SAP_SLOG_REGEX = /^\|\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s*\|\s*([EF])\s*\|\s*\d+\s*\|\s*(.+?)\s*\|/;
|
|
16
|
+
|
|
17
|
+
// Normalizes captured payload to errorCode string (or null when no recognizable code).
|
|
18
|
+
export function normalizeDevWMatch(match) {
|
|
19
|
+
if (!match) return null;
|
|
20
|
+
const raw = (match[1] || '').trim();
|
|
21
|
+
if (/^sy-subrc/i.test(raw)) {
|
|
22
|
+
const m = raw.match(/=\s*(\d+)/);
|
|
23
|
+
return m ? `SY-SUBRC=${m[1]}` : 'SY-SUBRC';
|
|
24
|
+
}
|
|
25
|
+
return raw.toUpperCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeSlogMatch(match) {
|
|
29
|
+
if (!match) return null;
|
|
30
|
+
const body = (match[2] || '').trim();
|
|
31
|
+
// Try to extract a known runtime-error keyword from the SLOG message body
|
|
32
|
+
const m = body.match(/(CONVT_NO_NUMBER|CX_SY_OPEN_SQL_DB|sy-subrc\s*=\s*\d+|Short dump \d+)/i);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const cap = m[1].trim();
|
|
35
|
+
if (/^sy-subrc/i.test(cap)) {
|
|
36
|
+
const num = cap.match(/=\s*(\d+)/);
|
|
37
|
+
return num ? `SY-SUBRC=${num[1]}` : 'SY-SUBRC';
|
|
38
|
+
}
|
|
39
|
+
if (/^Short dump/i.test(cap)) return 'SHORT_DUMP';
|
|
40
|
+
return cap.toUpperCase();
|
|
41
|
+
}
|
package/src/server.js
CHANGED
|
@@ -25,6 +25,7 @@ import { ConfigAnalyzer } from "./analyzers/config-analyzer.js";
|
|
|
25
25
|
import { WorkspaceManager } from "./workspace-manager.js";
|
|
26
26
|
import { startMCPHttpServer } from "./mcp-http-server.js";
|
|
27
27
|
import { registerVercelRoutes } from "./vercel-proxy.js";
|
|
28
|
+
import { loadAdapters, stopAll } from "./adapters/index.js";
|
|
28
29
|
|
|
29
30
|
const execAsync = promisify(exec);
|
|
30
31
|
|
|
@@ -541,6 +542,10 @@ class AIVibeCodingEngine extends EventEmitter {
|
|
|
541
542
|
// Instncia global do AI Engine
|
|
542
543
|
let aiEngine = null;
|
|
543
544
|
|
|
545
|
+
// Phase 2 — Legacy connector adapters (lifecycle owned by SIGTERM/SIGINT handlers below).
|
|
546
|
+
// Populated at boot from LEGACY_STACKS env var; empty when env var unset (zero-regression).
|
|
547
|
+
let _legacyAdapters = [];
|
|
548
|
+
|
|
544
549
|
const app = express();
|
|
545
550
|
|
|
546
551
|
// FIXED: Support Cloud Run PORT environment variable (GCP uses PORT)
|
|
@@ -644,6 +649,31 @@ const MCP_PORT = process.env.MCP_PORT || 5056;
|
|
|
644
649
|
// Inicializar AI Vibe Coding Engine
|
|
645
650
|
aiEngine = new AIVibeCodingEngine(processManager, () => WORKSPACE_ROOT);
|
|
646
651
|
|
|
652
|
+
// Phase 2 — bootstrap legacy connectors from LEGACY_STACKS env var.
|
|
653
|
+
// Adapters are loaded UNSTARTED by the registry (AD-1); we start them inline so that
|
|
654
|
+
// the existing local-agent flow is byte-for-byte unchanged when LEGACY_STACKS is unset.
|
|
655
|
+
// Errors during start surface via the adapter's own 'error' event; the loop never throws.
|
|
656
|
+
try {
|
|
657
|
+
const stacks = (process.env.LEGACY_STACKS || '')
|
|
658
|
+
.split(',')
|
|
659
|
+
.map((s) => s.trim())
|
|
660
|
+
.filter(Boolean);
|
|
661
|
+
if (stacks.length > 0) {
|
|
662
|
+
_legacyAdapters = loadAdapters({ legacyStacks: stacks });
|
|
663
|
+
for (const a of _legacyAdapters) {
|
|
664
|
+
// Start failures are surfaced via the adapter's 'error' event; do not crash boot.
|
|
665
|
+
Promise.resolve()
|
|
666
|
+
.then(() => a.start())
|
|
667
|
+
.catch((err) => {
|
|
668
|
+
process.stderr.write(`legacy-connectors: ${a.constructor.name}.start() failed: ${err.message}\n`);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
} catch (err) {
|
|
673
|
+
// Bad LEGACY_STACKS = programmer/operator error; emit to stderr without crashing boot.
|
|
674
|
+
process.stderr.write(`legacy-connectors: ${err.message}\n`);
|
|
675
|
+
}
|
|
676
|
+
|
|
647
677
|
// ============================================
|
|
648
678
|
// BACKUP STORAGE (Sprint 1.3)
|
|
649
679
|
// In-memory backup storage with configurable max size
|
|
@@ -6250,8 +6280,22 @@ async function startWebSocketTunnel(port, gatewayUrl, apiKey, tenantId) {
|
|
|
6250
6280
|
}
|
|
6251
6281
|
}
|
|
6252
6282
|
|
|
6253
|
-
process.on('SIGINT', () => {
|
|
6254
|
-
|
|
6283
|
+
process.on('SIGINT', async () => {
|
|
6284
|
+
isShuttingDown = true;
|
|
6285
|
+
if (ws) ws.close();
|
|
6286
|
+
// Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
|
|
6287
|
+
try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
|
|
6288
|
+
// Preserve existing process model: signal trap does NOT call process.exit();
|
|
6289
|
+
// Node exits naturally once the event loop drains.
|
|
6290
|
+
});
|
|
6291
|
+
process.on('SIGTERM', async () => {
|
|
6292
|
+
isShuttingDown = true;
|
|
6293
|
+
if (ws) ws.close();
|
|
6294
|
+
// Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
|
|
6295
|
+
try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
|
|
6296
|
+
// Preserve existing process model: signal trap does NOT call process.exit();
|
|
6297
|
+
// Node exits naturally once the event loop drains.
|
|
6298
|
+
});
|
|
6255
6299
|
connect();
|
|
6256
6300
|
}
|
|
6257
6301
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
|
+
import { As400LogAdapter } from '../../../src/adapters/as400/index.js';
|
|
6
|
+
import { AS400_MSGID_REGEX } from '../../../src/adapters/as400/as400-parser.js';
|
|
7
|
+
|
|
8
|
+
// Stub ssh2.Client — supports .connect(), .exec(callback), .on('close'/'error'/'ready'), .end()
|
|
9
|
+
class StubSshClient extends EventEmitter {
|
|
10
|
+
constructor() {
|
|
11
|
+
super();
|
|
12
|
+
this.connectCalls = [];
|
|
13
|
+
this.execCalls = [];
|
|
14
|
+
this._channels = [];
|
|
15
|
+
this._cannedOutput = '';
|
|
16
|
+
}
|
|
17
|
+
connect(opts) {
|
|
18
|
+
this.connectCalls.push(opts);
|
|
19
|
+
// emit 'ready' on next tick
|
|
20
|
+
process.nextTick(() => this.emit('ready'));
|
|
21
|
+
}
|
|
22
|
+
exec(cmd, cb) {
|
|
23
|
+
this.execCalls.push(cmd);
|
|
24
|
+
const stream = new EventEmitter();
|
|
25
|
+
stream.stderr = new EventEmitter();
|
|
26
|
+
process.nextTick(() => {
|
|
27
|
+
cb(null, stream);
|
|
28
|
+
// Push canned output then close.
|
|
29
|
+
stream.emit('data', Buffer.from(this._cannedOutput || ''));
|
|
30
|
+
stream.emit('close', 0);
|
|
31
|
+
});
|
|
32
|
+
this._channels.push(stream);
|
|
33
|
+
}
|
|
34
|
+
end() {
|
|
35
|
+
process.nextTick(() => this.emit('close'));
|
|
36
|
+
}
|
|
37
|
+
setCannedOutput(text) { this._cannedOutput = text; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('As400LogAdapter', () => {
|
|
41
|
+
test('constructor sets keepalive defaults (T-2-02)', () => {
|
|
42
|
+
const adapter = new As400LogAdapter({ host: '1.2.3.4', username: 'u', password: 'p' });
|
|
43
|
+
assert.equal(adapter._sshOpts.keepaliveInterval, 30000);
|
|
44
|
+
assert.equal(adapter._sshOpts.keepaliveCountMax, 3);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('emits 6-key event for CPF/MCH/SQL lines (AC-2)', async () => {
|
|
48
|
+
const stub = new StubSshClient();
|
|
49
|
+
stub.setCannedOutput([
|
|
50
|
+
'CPF1234 ERROR Job DAILY01 ended abnormally',
|
|
51
|
+
'INFO This line should not match',
|
|
52
|
+
'MCH3601 Pointer not set for location *N',
|
|
53
|
+
''
|
|
54
|
+
].join('\n'));
|
|
55
|
+
const adapter = new As400LogAdapter({
|
|
56
|
+
host: '1.2.3.4', username: 'u', password: 'p',
|
|
57
|
+
pollInterval: 100, sshClientFactory: () => stub,
|
|
58
|
+
});
|
|
59
|
+
const events = [];
|
|
60
|
+
adapter.on('error', (e) => events.push(e));
|
|
61
|
+
await adapter.start();
|
|
62
|
+
await sleep(400);
|
|
63
|
+
await adapter.stop();
|
|
64
|
+
assert.ok(events.length >= 2, `expected 2 events, got ${events.length}`);
|
|
65
|
+
const e = events[0];
|
|
66
|
+
assert.equal(Object.keys(e).sort().join(','), 'adapter,errorCode,file,message,source,timestamp');
|
|
67
|
+
assert.equal(e.adapter, 'as400');
|
|
68
|
+
assert.match(e.errorCode, /^(CPF|MCH|SQL)\d{4}$/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('non-matching lines do not emit', async () => {
|
|
72
|
+
const stub = new StubSshClient();
|
|
73
|
+
stub.setCannedOutput('INFO Just an info line\nDEBUG diag\n');
|
|
74
|
+
const adapter = new As400LogAdapter({
|
|
75
|
+
host: '1.2.3.4', username: 'u', password: 'p',
|
|
76
|
+
pollInterval: 100, sshClientFactory: () => stub,
|
|
77
|
+
});
|
|
78
|
+
const events = [];
|
|
79
|
+
adapter.on('error', (e) => events.push(e));
|
|
80
|
+
await adapter.start();
|
|
81
|
+
await sleep(400);
|
|
82
|
+
await adapter.stop();
|
|
83
|
+
assert.equal(events.length, 0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('reconnect on close with exponential backoff (T-2-02)', async () => {
|
|
87
|
+
const factories = [];
|
|
88
|
+
const adapter = new As400LogAdapter({
|
|
89
|
+
host: '1.2.3.4', username: 'u', password: 'p',
|
|
90
|
+
pollInterval: 60000, // no poll churn
|
|
91
|
+
sshClientFactory: () => { const s = new StubSshClient(); factories.push(s); return s; },
|
|
92
|
+
backoffSchedule: [50, 100, 200], // shortened for test
|
|
93
|
+
});
|
|
94
|
+
await adapter.start();
|
|
95
|
+
await sleep(50);
|
|
96
|
+
// First client connected; force a close.
|
|
97
|
+
assert.ok(factories.length >= 1);
|
|
98
|
+
factories[0].emit('close');
|
|
99
|
+
await sleep(200); // wait for first backoff (50ms) to fire reconnect
|
|
100
|
+
assert.ok(factories.length >= 2, 'reconnect must spawn a second client after close');
|
|
101
|
+
await adapter.stop();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('lifecycle idempotent (AC-1)', async () => {
|
|
105
|
+
const stub = new StubSshClient();
|
|
106
|
+
const adapter = new As400LogAdapter({
|
|
107
|
+
host: '1.2.3.4', username: 'u', password: 'p',
|
|
108
|
+
pollInterval: 60000, sshClientFactory: () => stub,
|
|
109
|
+
});
|
|
110
|
+
await adapter.start();
|
|
111
|
+
await adapter.start();
|
|
112
|
+
await adapter.stop();
|
|
113
|
+
await adapter.stop();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('parser regex exported and isolated', () => {
|
|
117
|
+
assert.ok(AS400_MSGID_REGEX instanceof RegExp);
|
|
118
|
+
assert.ok('CPF1234 ...'.match(AS400_MSGID_REGEX));
|
|
119
|
+
assert.equal('INFO ...'.match(AS400_MSGID_REGEX), null);
|
|
120
|
+
});
|
|
121
|
+
});
|