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.
@@ -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', () => { isShuttingDown = true; if (ws) ws.close(); });
6254
- process.on('SIGTERM', () => { isShuttingDown = true; if (ws) ws.close(); });
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
+ });