@worca/app 0.0.1
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 +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// src/core/chat/channel-host.mjs
|
|
2
|
+
// Supervisor for persistent channel workers (chat-connectivity-design.md §4.4).
|
|
3
|
+
// One child process per enabled+configured chatChannels entry: spawned with the
|
|
4
|
+
// same scrubbed env as connector ops, fed config/secrets/state via the hello
|
|
5
|
+
// frame on stdin, restarted with backoff on crash, killed on plugin
|
|
6
|
+
// disable/uninstall and on server shutdown. All worker-originated strings are
|
|
7
|
+
// redacted before they reach the logger or the UI.
|
|
8
|
+
//
|
|
9
|
+
// Worker contract: a worker that emits any 'status' frame before 'ready' will
|
|
10
|
+
// NOT be flipped to 'connected' by that 'ready' — the documented no-throw
|
|
11
|
+
// auth-failure pattern (emit 'disconnected' with the reason, then return from
|
|
12
|
+
// start()) relies on this. Workers that self-report 'connected' are unaffected:
|
|
13
|
+
// their own status frame does it.
|
|
14
|
+
//
|
|
15
|
+
// WORCA_MOCK=1: no children — in-memory mock workers, instantly 'connected',
|
|
16
|
+
// with test hooks mirroring plugin-shim.mjs setMockSourceResponses.
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { createInterface } from 'node:readline';
|
|
23
|
+
import { PluginOpError, scrubbedEnv } from '../plugin-shim.mjs';
|
|
24
|
+
import { normalizeManifest, negotiatedApi } from '../plugin-manifest.mjs';
|
|
25
|
+
import { WORCA_PLUGIN_API } from '../plugin-api.mjs';
|
|
26
|
+
import { readPluginsLock, pluginCurrentDir } from '../plugins-lock.mjs';
|
|
27
|
+
import { readPluginConfig, readPluginState, writePluginState } from '../plugin-config.mjs';
|
|
28
|
+
import { encodeFrame, decodeFrame, isValidMessage, MAX_FRAME_BYTES } from './channel-protocol.mjs';
|
|
29
|
+
import { redactSecrets } from './redact.mjs';
|
|
30
|
+
|
|
31
|
+
const CHILD_PATH = fileURLToPath(new URL('./channel-worker-child.mjs', import.meta.url));
|
|
32
|
+
|
|
33
|
+
const RESTART_BACKOFF_MS = [1000, 5000, 30000, 60000];
|
|
34
|
+
const MAX_CONSECUTIVE_FAILURES = 10;
|
|
35
|
+
const HEALTHY_AFTER_MS = 60000;
|
|
36
|
+
const PING_INTERVAL_MS = 30000;
|
|
37
|
+
const SHUTDOWN_GRACE_MS = 5000;
|
|
38
|
+
const MAX_OUT_QUEUE = 100;
|
|
39
|
+
const DELIVERY_RING_SIZE = 50;
|
|
40
|
+
|
|
41
|
+
const workerKey = (plugin, channelId) => `${plugin}/${channelId}`;
|
|
42
|
+
|
|
43
|
+
/** Same env-flag semantics as plugin-shim.mjs#mockMode. */
|
|
44
|
+
function mockMode() {
|
|
45
|
+
const v = process.env.WORCA_MOCK ?? process.env.ORCH_MOCK;
|
|
46
|
+
return !!v && v !== '0' && v.toLowerCase() !== 'false';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── mock hooks (tests / offline smoke) ───────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
let _mockBehavior = null; // key or '*' -> { send?(chatId, message), webhook?(req) }
|
|
52
|
+
const _mockSent = []; // every sendMessage seen in mock mode
|
|
53
|
+
|
|
54
|
+
/** Tests: override mock worker behavior per "plugin/channelId" key ('*' = all). */
|
|
55
|
+
export function setMockChannelBehavior(map) {
|
|
56
|
+
_mockBehavior = map && typeof map === 'object' ? map : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tests: messages delivered through mock workers, oldest first. */
|
|
60
|
+
export function mockSentMessages() { return _mockSent.slice(); }
|
|
61
|
+
|
|
62
|
+
/** Tests: reset recorded mock deliveries. */
|
|
63
|
+
export function clearMockSentMessages() { _mockSent.length = 0; }
|
|
64
|
+
|
|
65
|
+
// ── discovery ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Enabled plugins' chatChannels, standard discovery idiom (lock -> sorted ->
|
|
69
|
+
* enabled -> current/ manifest), failing closed to []. Each entry carries what
|
|
70
|
+
* the supervisor needs to spawn and what the UI needs to display.
|
|
71
|
+
*/
|
|
72
|
+
export function discoverChannels() {
|
|
73
|
+
let lock;
|
|
74
|
+
try { lock = readPluginsLock(); } catch { return []; }
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const name of Object.keys(lock).sort()) {
|
|
77
|
+
if (lock[name]?.enabled === false) continue;
|
|
78
|
+
const dir = pluginCurrentDir(name);
|
|
79
|
+
let manifest;
|
|
80
|
+
try {
|
|
81
|
+
const norm = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
|
|
82
|
+
if (!norm.ok) continue;
|
|
83
|
+
manifest = norm.manifest;
|
|
84
|
+
} catch { continue; }
|
|
85
|
+
for (const ch of manifest.chatChannels || []) {
|
|
86
|
+
const missing = (ch.configSchema || [])
|
|
87
|
+
.filter((f) => f.required)
|
|
88
|
+
.filter((f) => {
|
|
89
|
+
const v = readPluginConfig(name, ch.configSchema)[f.key];
|
|
90
|
+
return v === undefined || v === null || v === '';
|
|
91
|
+
})
|
|
92
|
+
.map((f) => f.key);
|
|
93
|
+
out.push({
|
|
94
|
+
plugin: name,
|
|
95
|
+
channelId: ch.id,
|
|
96
|
+
displayName: ch.displayName,
|
|
97
|
+
platform: ch.platform,
|
|
98
|
+
ingress: ch.ingress,
|
|
99
|
+
capabilities: ch.capabilities,
|
|
100
|
+
configSchema: ch.configSchema,
|
|
101
|
+
modulePath: resolve(dir, ch.module),
|
|
102
|
+
apiVersion: negotiatedApi(manifest.engines?.worcaApi) ?? WORCA_PLUGIN_API,
|
|
103
|
+
missingConfig: missing,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── supervisor ───────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {{logger?:(level:string,msg:string)=>void,
|
|
114
|
+
* onInbound?:(ev:{plugin:string,channelId:string,platform:string,msg:object})=>void,
|
|
115
|
+
* onStatus?:(ev:{plugin:string,channelId:string,platform:string,state:string,detail:string|null})=>void,
|
|
116
|
+
* _spawn?:typeof spawn, _backoffMs?:number[], _healthyAfterMs?:number}} opts
|
|
117
|
+
* `_spawn`/`_backoffMs`/`_healthyAfterMs` are test seams only.
|
|
118
|
+
*/
|
|
119
|
+
export function createChannelHost({ logger, onInbound, onStatus, _spawn = spawn, _backoffMs = RESTART_BACKOFF_MS, _healthyAfterMs = HEALTHY_AFTER_MS } = {}) {
|
|
120
|
+
const log = typeof logger === 'function' ? logger : (level, msg) => console.error(`chat ${level}: ${msg}`);
|
|
121
|
+
const workers = new Map(); // key -> worker record
|
|
122
|
+
let stopping = false;
|
|
123
|
+
|
|
124
|
+
// Per-worker redactor: pattern scrubs (redact.mjs) PLUS literal occurrences
|
|
125
|
+
// of this channel's secret config VALUES — patterns can't know an arbitrary
|
|
126
|
+
// token a user pasted into a secret field.
|
|
127
|
+
const makeRedactor = (entry) => {
|
|
128
|
+
let values = [];
|
|
129
|
+
try {
|
|
130
|
+
const cfg = readPluginConfig(entry.plugin, entry.configSchema);
|
|
131
|
+
values = (entry.configSchema || [])
|
|
132
|
+
.filter((f) => f.secret)
|
|
133
|
+
.map((f) => cfg[f.key])
|
|
134
|
+
.filter((v) => typeof v === 'string' && v.length >= 4);
|
|
135
|
+
} catch { /* discovery already logged */ }
|
|
136
|
+
return (s) => values.reduce((acc, v) => acc.split(v).join('<redacted>'), redactSecrets(s));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const setStatus = (w, state, detail = null) => {
|
|
140
|
+
const red = detail == null ? null : w.redact(detail);
|
|
141
|
+
if (w.state === state && w.detail === red) return;
|
|
142
|
+
// healthySince = when we last BELIEVED this worker connected. Stamped on the
|
|
143
|
+
// transition only, so a masked 'ready' (worker already said disconnected)
|
|
144
|
+
// can never buy the exit handler's forgiveness.
|
|
145
|
+
if (state === 'connected' && w.state !== 'connected') w.healthySince = Date.now();
|
|
146
|
+
w.state = state;
|
|
147
|
+
w.detail = red;
|
|
148
|
+
try { onStatus?.({ plugin: w.entry.plugin, channelId: w.entry.channelId, platform: w.entry.platform, state, detail: red }); }
|
|
149
|
+
catch { /* listener faults never reach the supervisor */ }
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const rejectPending = (w, err) => {
|
|
153
|
+
for (const [, p] of w.pending) { clearTimeout(p.timer); p.reject(err); }
|
|
154
|
+
w.pending.clear();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const pushDelivery = (w, rec) => {
|
|
158
|
+
w.deliveries.push({ at: new Date().toISOString(), ...rec });
|
|
159
|
+
if (w.deliveries.length > DELIVERY_RING_SIZE) w.deliveries.shift();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Bounded outbound frame queue: drop-oldest at MAX_OUT_QUEUE, counted; a
|
|
163
|
+
// dropped send RPC rejects immediately instead of hanging to timeout.
|
|
164
|
+
const writeFrame = (w, frame) => {
|
|
165
|
+
if (!w.proc || w.proc.killed) throw new PluginOpError('plugin', 'worker is not running');
|
|
166
|
+
if (w.backpressured || w.outQueue.length) {
|
|
167
|
+
w.outQueue.push(frame);
|
|
168
|
+
while (w.outQueue.length > MAX_OUT_QUEUE) {
|
|
169
|
+
const dropped = w.outQueue.shift();
|
|
170
|
+
w.dropped += 1;
|
|
171
|
+
const p = dropped?.id ? w.pending.get(dropped.id) : null;
|
|
172
|
+
if (p) { w.pending.delete(dropped.id); clearTimeout(p.timer); p.reject(new PluginOpError('rate-limit', 'outbound queue overflow — frame dropped')); }
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const ok = w.proc.stdin.write(encodeFrame(frame));
|
|
177
|
+
if (!ok) {
|
|
178
|
+
w.backpressured = true;
|
|
179
|
+
w.proc.stdin.once('drain', () => flushQueue(w));
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const flushQueue = (w) => {
|
|
183
|
+
w.backpressured = false;
|
|
184
|
+
while (w.outQueue.length && w.proc && !w.proc.killed) {
|
|
185
|
+
if (!w.proc.stdin.write(encodeFrame(w.outQueue.shift()))) {
|
|
186
|
+
w.backpressured = true;
|
|
187
|
+
w.proc.stdin.once('drain', () => flushQueue(w));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const handleFrame = (w, frame) => {
|
|
194
|
+
switch (frame.type) {
|
|
195
|
+
case 'ready':
|
|
196
|
+
w.identity = frame.identity ?? null;
|
|
197
|
+
// NOT w.consecutiveFailures = 0 — the exit handler already forgives a
|
|
198
|
+
// crash after _healthyAfterMs of health; zeroing here let a
|
|
199
|
+
// connect-then-crash worker restart at the 1s floor forever.
|
|
200
|
+
// NOT w.healthySince here either — setStatus stamps it on the real
|
|
201
|
+
// transition, so a masked ready cannot buy forgiveness.
|
|
202
|
+
// 'ready' is optimistic: it only claims 'connected' when the worker has
|
|
203
|
+
// not already reported its own state this spawn (the documented no-throw
|
|
204
|
+
// auth-failure pattern emits 'disconnected' BEFORE ready).
|
|
205
|
+
if (!w.statusSinceSpawn) setStatus(w, 'connected', null);
|
|
206
|
+
break;
|
|
207
|
+
case 'status':
|
|
208
|
+
w.statusSinceSpawn = true;
|
|
209
|
+
setStatus(w, frame.state, frame.detail ?? null);
|
|
210
|
+
break;
|
|
211
|
+
case 'inbound':
|
|
212
|
+
if (!w.entry.capabilities.inbound) break; // manifest said outbound-only: drop
|
|
213
|
+
try { onInbound?.({ plugin: w.entry.plugin, channelId: w.entry.channelId, platform: w.entry.platform, msg: frame.msg }); }
|
|
214
|
+
catch (err) { log('error', `chat inbound handler failed: ${w.redact(err?.message || err)}`); }
|
|
215
|
+
break;
|
|
216
|
+
case 'send-result':
|
|
217
|
+
case 'webhook-result': {
|
|
218
|
+
const p = w.pending.get(frame.id);
|
|
219
|
+
if (!p) break;
|
|
220
|
+
w.pending.delete(frame.id);
|
|
221
|
+
clearTimeout(p.timer);
|
|
222
|
+
p.resolve(frame);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case 'state-delta':
|
|
226
|
+
try { writePluginState(w.entry.plugin, frame.delta); }
|
|
227
|
+
catch (err) { log('error', `chat state persist failed for ${w.key}: ${err?.message || err}`); }
|
|
228
|
+
break;
|
|
229
|
+
case 'pong':
|
|
230
|
+
w.missedPongs = 0;
|
|
231
|
+
break;
|
|
232
|
+
case 'log':
|
|
233
|
+
log(frame.level || 'info', `[chat:${w.key}] ${w.redact(frame.msg)}`);
|
|
234
|
+
break;
|
|
235
|
+
default: // host->worker types echoed back: protocol noise, ignore
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const spawnWorker = (w) => {
|
|
241
|
+
if (stopping) return;
|
|
242
|
+
setStatus(w, 'connecting', null);
|
|
243
|
+
const proc = _spawn(process.execPath,
|
|
244
|
+
[...(process.env.WORCA_PLUGIN_INSPECT ? ['--inspect'] : []), CHILD_PATH],
|
|
245
|
+
{ env: scrubbedEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
246
|
+
w.proc = proc;
|
|
247
|
+
w.missedPongs = 0;
|
|
248
|
+
w.spawnedAt = Date.now();
|
|
249
|
+
w.statusSinceSpawn = false;
|
|
250
|
+
|
|
251
|
+
proc.stdin.on('error', () => { /* EPIPE on dying child: exit handler owns recovery */ });
|
|
252
|
+
let stderrTail = '';
|
|
253
|
+
proc.stderr.setEncoding('utf8');
|
|
254
|
+
proc.stderr.on('data', (d) => { stderrTail = (stderrTail + d).slice(-2000); });
|
|
255
|
+
|
|
256
|
+
const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
|
|
257
|
+
rl.on('line', (line) => {
|
|
258
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_FRAME_BYTES) {
|
|
259
|
+
log('error', `[chat:${w.key}] oversize frame — protocol violation, restarting worker`);
|
|
260
|
+
proc.kill('SIGKILL');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const frame = decodeFrame(line);
|
|
264
|
+
if (!frame) { log('warn', `[chat:${w.key}] invalid worker frame dropped`); return; }
|
|
265
|
+
handleFrame(w, frame);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
w.pingTimer = setInterval(() => {
|
|
269
|
+
if (!w.proc || w.proc.killed) return;
|
|
270
|
+
w.missedPongs += 1;
|
|
271
|
+
if (w.missedPongs > 2) {
|
|
272
|
+
log('warn', `[chat:${w.key}] unresponsive (missed pongs) — restarting worker`);
|
|
273
|
+
w.proc.kill('SIGKILL');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
try { writeFrame(w, { type: 'ping', id: `ping-${Date.now()}` }); } catch { /* exit handler owns it */ }
|
|
277
|
+
}, PING_INTERVAL_MS);
|
|
278
|
+
w.pingTimer.unref?.();
|
|
279
|
+
|
|
280
|
+
const onExit = (code, signal) => {
|
|
281
|
+
if (w.proc !== proc) return; // stale duplicate: error path ran, or a respawn happened
|
|
282
|
+
clearInterval(w.pingTimer);
|
|
283
|
+
w.proc = null;
|
|
284
|
+
w.outQueue.length = 0;
|
|
285
|
+
w.backpressured = false;
|
|
286
|
+
rejectPending(w, new PluginOpError('plugin', `worker exited (${signal || code})`));
|
|
287
|
+
if (stopping || w.removed) { setStatus(w, 'disconnected', null); return; }
|
|
288
|
+
if (w.shuttingDown) { w.shuttingDown = false; setStatus(w, 'disconnected', null); return; }
|
|
289
|
+
|
|
290
|
+
const healthyLongEnough = w.healthySince && Date.now() - w.healthySince > _healthyAfterMs;
|
|
291
|
+
w.consecutiveFailures = healthyLongEnough ? 1 : w.consecutiveFailures + 1;
|
|
292
|
+
w.healthySince = null;
|
|
293
|
+
const detail = w.spawnErrorDetail || stderrTail.trim().split('\n').pop() || `exit ${signal || code}`;
|
|
294
|
+
w.spawnErrorDetail = null;
|
|
295
|
+
if (w.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
296
|
+
log('error', `[chat:${w.key}] giving up after ${w.consecutiveFailures} failures: ${w.redact(detail)}`);
|
|
297
|
+
setStatus(w, 'failed', detail);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const delay = _backoffMs[Math.min(w.consecutiveFailures - 1, _backoffMs.length - 1)];
|
|
301
|
+
log('warn', `[chat:${w.key}] worker exited (${signal || code}) — restart in ${delay}ms`);
|
|
302
|
+
setStatus(w, 'disconnected', detail);
|
|
303
|
+
w.restartTimer = setTimeout(() => spawnWorker(w), delay);
|
|
304
|
+
w.restartTimer.unref?.();
|
|
305
|
+
};
|
|
306
|
+
proc.on('exit', onExit);
|
|
307
|
+
// Async spawn/pipe failures (EMFILE, EAGAIN) must never become an unhandled
|
|
308
|
+
// 'error' on the child. If spawn failed outright (no pid), 'exit' will never
|
|
309
|
+
// fire — stash the real error text (stderrTail is empty on this path, so the
|
|
310
|
+
// badge would otherwise show a useless "exit -1") and route into recovery.
|
|
311
|
+
proc.on('error', (err) => {
|
|
312
|
+
const msg = w.redact(err?.message || String(err));
|
|
313
|
+
log('error', `[chat:${w.key}] worker process error: ${msg}`);
|
|
314
|
+
if (proc.pid) { try { proc.kill('SIGKILL'); } catch { /* gone */ } }
|
|
315
|
+
else { w.spawnErrorDetail = msg; onExit(-1, null); }
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// hello: config+secrets+state via stdin ONLY (spec §7.2 rule, persistent).
|
|
319
|
+
try {
|
|
320
|
+
writeFrame(w, {
|
|
321
|
+
type: 'hello',
|
|
322
|
+
apiVersion: w.entry.apiVersion,
|
|
323
|
+
plugin: w.entry.plugin,
|
|
324
|
+
channelId: w.entry.channelId,
|
|
325
|
+
platform: w.entry.platform,
|
|
326
|
+
module: w.entry.modulePath,
|
|
327
|
+
config: readPluginConfig(w.entry.plugin, w.entry.configSchema),
|
|
328
|
+
state: readPluginState(w.entry.plugin),
|
|
329
|
+
mock: false,
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
log('error', `[chat:${w.key}] hello failed: ${w.redact(err?.message || err)}`);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const makeRecord = (entry) => ({
|
|
337
|
+
key: workerKey(entry.plugin, entry.channelId),
|
|
338
|
+
entry,
|
|
339
|
+
redact: makeRedactor(entry),
|
|
340
|
+
proc: null,
|
|
341
|
+
state: 'disconnected',
|
|
342
|
+
detail: null,
|
|
343
|
+
identity: null,
|
|
344
|
+
pending: new Map(),
|
|
345
|
+
outQueue: [],
|
|
346
|
+
backpressured: false,
|
|
347
|
+
dropped: 0,
|
|
348
|
+
deliveries: [],
|
|
349
|
+
consecutiveFailures: 0,
|
|
350
|
+
healthySince: null,
|
|
351
|
+
statusSinceSpawn: false,
|
|
352
|
+
spawnErrorDetail: null,
|
|
353
|
+
missedPongs: 0,
|
|
354
|
+
shuttingDown: false,
|
|
355
|
+
removed: false,
|
|
356
|
+
pingTimer: null,
|
|
357
|
+
restartTimer: null,
|
|
358
|
+
mock: false,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const startEntry = (entry) => {
|
|
362
|
+
const key = workerKey(entry.plugin, entry.channelId);
|
|
363
|
+
if (workers.has(key)) return;
|
|
364
|
+
const w = makeRecord(entry);
|
|
365
|
+
workers.set(key, w);
|
|
366
|
+
if (entry.missingConfig.length) {
|
|
367
|
+
setStatus(w, 'unconfigured', `missing config: ${entry.missingConfig.join(', ')}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (mockMode()) {
|
|
371
|
+
w.mock = true;
|
|
372
|
+
w.identity = 'mock';
|
|
373
|
+
setStatus(w, 'connected', null);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
spawnWorker(w);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const stopWorker = async (w) => {
|
|
380
|
+
w.removed = true;
|
|
381
|
+
clearTimeout(w.restartTimer);
|
|
382
|
+
clearInterval(w.pingTimer);
|
|
383
|
+
const proc = w.proc;
|
|
384
|
+
if (!proc || proc.killed) { setStatus(w, 'disconnected', null); return; }
|
|
385
|
+
w.shuttingDown = true;
|
|
386
|
+
try { writeFrame(w, { type: 'shutdown' }); } catch { /* dying already */ }
|
|
387
|
+
await new Promise((resolveWait) => {
|
|
388
|
+
const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* gone */ } }, SHUTDOWN_GRACE_MS);
|
|
389
|
+
t.unref?.();
|
|
390
|
+
proc.once('exit', () => { clearTimeout(t); resolveWait(); });
|
|
391
|
+
if (proc.exitCode !== null) { clearTimeout(t); resolveWait(); }
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const rpc = (w, frame, timeoutMs, kindOnTimeout = 'timeout') => new Promise((resolveRpc, rejectRpc) => {
|
|
396
|
+
const timer = setTimeout(() => {
|
|
397
|
+
w.pending.delete(frame.id);
|
|
398
|
+
// Still queued behind backpressure? Drop it: a later drain must not
|
|
399
|
+
// deliver a send the caller was already told had failed.
|
|
400
|
+
const qi = w.outQueue.findIndex((f) => f && f.id === frame.id);
|
|
401
|
+
if (qi >= 0) w.outQueue.splice(qi, 1); // NOT counted in w.dropped (that means queue overflow)
|
|
402
|
+
rejectRpc(new PluginOpError(kindOnTimeout, `worker did not answer within ${timeoutMs}ms`));
|
|
403
|
+
}, timeoutMs);
|
|
404
|
+
timer.unref?.();
|
|
405
|
+
w.pending.set(frame.id, { resolve: resolveRpc, reject: rejectRpc, timer });
|
|
406
|
+
try { writeFrame(w, frame); } catch (err) {
|
|
407
|
+
w.pending.delete(frame.id);
|
|
408
|
+
clearTimeout(timer);
|
|
409
|
+
rejectRpc(err instanceof PluginOpError ? err : new PluginOpError('plugin', err?.message || String(err)));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
let rpcSeq = 0;
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
/** Discover + start a worker per enabled, fully-configured channel. With a
|
|
417
|
+
* filter, start ONLY that channel — `worca plugin channel` must not spawn
|
|
418
|
+
* every configured channel (a second telegram long-poller 409s the live
|
|
419
|
+
* server's worker).
|
|
420
|
+
* @param {{plugin:string,channelId:string}} [filter] */
|
|
421
|
+
start(filter) {
|
|
422
|
+
for (const entry of discoverChannels()) {
|
|
423
|
+
if (filter && (entry.plugin !== filter.plugin || entry.channelId !== filter.channelId)) continue;
|
|
424
|
+
startEntry(entry);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
/** Shutdown frame -> grace -> SIGKILL, for every worker. */
|
|
429
|
+
async stop() {
|
|
430
|
+
stopping = true;
|
|
431
|
+
await Promise.all([...workers.values()].map((w) => stopWorker(w)));
|
|
432
|
+
workers.clear();
|
|
433
|
+
stopping = false;
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
/** Restart the plugin's workers against fresh manifest/config (enable,
|
|
437
|
+
* disable, config save, install, update, uninstall all route here). */
|
|
438
|
+
async reloadPlugin(name) {
|
|
439
|
+
const mine = [...workers.values()].filter((w) => w.entry.plugin === name);
|
|
440
|
+
await Promise.all(mine.map((w) => stopWorker(w)));
|
|
441
|
+
for (const w of mine) workers.delete(w.key);
|
|
442
|
+
for (const entry of discoverChannels().filter((e) => e.plugin === name)) startEntry(entry);
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
list() { return [...workers.values()].map((w) => w.entry); },
|
|
446
|
+
|
|
447
|
+
/** UI/doctor status rows (redacted, ring-buffered deliveries included). */
|
|
448
|
+
status() {
|
|
449
|
+
return [...workers.values()].map((w) => ({
|
|
450
|
+
plugin: w.entry.plugin,
|
|
451
|
+
channelId: w.entry.channelId,
|
|
452
|
+
displayName: w.entry.displayName,
|
|
453
|
+
platform: w.entry.platform,
|
|
454
|
+
ingress: w.entry.ingress,
|
|
455
|
+
capabilities: w.entry.capabilities,
|
|
456
|
+
state: w.state,
|
|
457
|
+
detail: w.detail,
|
|
458
|
+
identity: w.identity,
|
|
459
|
+
dropped: w.dropped,
|
|
460
|
+
restarts: w.consecutiveFailures,
|
|
461
|
+
deliveries: w.deliveries.slice(),
|
|
462
|
+
}));
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Deliver one NormalizedMessage to one chat. Resolves {ok:true} or throws
|
|
467
|
+
* PluginOpError with the worker-reported kind. Mock mode records instead.
|
|
468
|
+
*/
|
|
469
|
+
async sendMessage({ plugin, channelId, chatId, message, timeoutMs = 15000 }) {
|
|
470
|
+
if (!isValidMessage(message)) throw new PluginOpError('plugin', 'invalid NormalizedMessage');
|
|
471
|
+
const w = workers.get(workerKey(plugin, channelId));
|
|
472
|
+
if (!w) throw new PluginOpError('plugin', `no such channel worker "${plugin}/${channelId}"`);
|
|
473
|
+
if (w.mock) {
|
|
474
|
+
const behavior = _mockBehavior?.[w.key] ?? _mockBehavior?.['*'];
|
|
475
|
+
_mockSent.push({ plugin, channelId, chatId, message });
|
|
476
|
+
if (behavior?.send) {
|
|
477
|
+
const r = await behavior.send(chatId, message); // may throw {kind,...}
|
|
478
|
+
pushDelivery(w, { chatId, ok: true });
|
|
479
|
+
return r ?? { ok: true };
|
|
480
|
+
}
|
|
481
|
+
pushDelivery(w, { chatId, ok: true });
|
|
482
|
+
return { ok: true };
|
|
483
|
+
}
|
|
484
|
+
if (w.state === 'unconfigured' || w.state === 'failed' || !w.proc) {
|
|
485
|
+
throw new PluginOpError('plugin', `channel worker is ${w.state}`);
|
|
486
|
+
}
|
|
487
|
+
const id = `s-${++rpcSeq}`;
|
|
488
|
+
const res = await rpc(w, { type: 'send', id, chatId, message }, timeoutMs);
|
|
489
|
+
if (res.ok) { pushDelivery(w, { chatId, ok: true }); return { ok: true }; }
|
|
490
|
+
pushDelivery(w, { chatId, ok: false, errorKind: res.error?.kind || 'plugin' });
|
|
491
|
+
const err = new PluginOpError(res.error?.kind || 'plugin', w.redact(res.error?.message || 'send failed'));
|
|
492
|
+
if (Number.isFinite(res.error?.retryAfterMs)) err.retryAfterMs = res.error.retryAfterMs;
|
|
493
|
+
throw err;
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
/** Teams ingress: forward one raw HTTP request to the worker, get back
|
|
497
|
+
* {statusCode, headers?, bodyB64?}. Worker down -> PluginOpError. */
|
|
498
|
+
async handleWebhook({ plugin, channelId, method, path, headers, bodyB64, timeoutMs = 10000 }) {
|
|
499
|
+
const w = workers.get(workerKey(plugin, channelId));
|
|
500
|
+
if (!w) throw new PluginOpError('plugin', `no such channel worker "${plugin}/${channelId}"`);
|
|
501
|
+
if (w.mock) {
|
|
502
|
+
const behavior = _mockBehavior?.[w.key] ?? _mockBehavior?.['*'];
|
|
503
|
+
if (behavior?.webhook) return behavior.webhook({ method, path, headers, bodyB64 });
|
|
504
|
+
return { statusCode: 200 };
|
|
505
|
+
}
|
|
506
|
+
if (!w.proc) throw new PluginOpError('plugin', `channel worker is ${w.state}`);
|
|
507
|
+
const id = `w-${++rpcSeq}`;
|
|
508
|
+
const res = await rpc(w, { type: 'webhook', id, method, path, headers, bodyB64 }, timeoutMs);
|
|
509
|
+
return { statusCode: res.statusCode, headers: res.headers, bodyB64: res.bodyB64 };
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
/** Tests/mocks: drive the full inbound pipeline without a process. */
|
|
513
|
+
injectInboundMessage(plugin, channelId, msg) {
|
|
514
|
+
const w = workers.get(workerKey(plugin, channelId));
|
|
515
|
+
if (!w) throw new Error(`no such channel worker "${plugin}/${channelId}"`);
|
|
516
|
+
handleFrame(w, { type: 'inbound', msg });
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
/** One-shot `--check` spawn: run the module's validateConfig offline
|
|
520
|
+
* (doctor / `worca plugin channel --check`). */
|
|
521
|
+
async checkChannel(plugin, channelId, { timeoutMs = 30000 } = {}) {
|
|
522
|
+
const entry = discoverChannels().find((e) => e.plugin === plugin && e.channelId === channelId);
|
|
523
|
+
if (!entry) throw new PluginOpError('plugin', `no such chat channel "${plugin}/${channelId}"`);
|
|
524
|
+
if (mockMode()) return { ok: true, mock: true };
|
|
525
|
+
return new Promise((resolveCheck, rejectCheck) => {
|
|
526
|
+
const proc = _spawn(process.execPath, [CHILD_PATH, '--check'], { env: scrubbedEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
527
|
+
let stdout = '';
|
|
528
|
+
let settled = false;
|
|
529
|
+
const timer = setTimeout(() => { settled = true; proc.kill('SIGKILL'); rejectCheck(new PluginOpError('timeout', `validateConfig timed out after ${timeoutMs}ms`)); }, timeoutMs);
|
|
530
|
+
timer.unref?.();
|
|
531
|
+
proc.stdout.setEncoding('utf8');
|
|
532
|
+
proc.stdout.on('data', (d) => { stdout += d; });
|
|
533
|
+
proc.on('exit', () => {
|
|
534
|
+
if (settled) return;
|
|
535
|
+
clearTimeout(timer);
|
|
536
|
+
const line = stdout.trim().split('\n').filter(Boolean).pop();
|
|
537
|
+
try { resolveCheck(JSON.parse(line)); }
|
|
538
|
+
catch { rejectCheck(new PluginOpError('protocol', 'validateConfig produced no result frame')); }
|
|
539
|
+
});
|
|
540
|
+
proc.on('error', (err) => {
|
|
541
|
+
if (settled) return;
|
|
542
|
+
settled = true;
|
|
543
|
+
clearTimeout(timer);
|
|
544
|
+
rejectCheck(new PluginOpError('plugin', `validateConfig child failed: ${err?.message || err}`));
|
|
545
|
+
});
|
|
546
|
+
proc.stdin.on('error', () => { /* EPIPE on dying child: exit/error handlers own it */ });
|
|
547
|
+
try {
|
|
548
|
+
proc.stdin.write(encodeFrame({
|
|
549
|
+
type: 'hello',
|
|
550
|
+
apiVersion: entry.apiVersion,
|
|
551
|
+
plugin, channelId,
|
|
552
|
+
platform: entry.platform,
|
|
553
|
+
module: entry.modulePath,
|
|
554
|
+
config: readPluginConfig(plugin, entry.configSchema),
|
|
555
|
+
state: readPluginState(plugin),
|
|
556
|
+
mock: false,
|
|
557
|
+
}));
|
|
558
|
+
} catch { /* dying child: handlers above settle the promise */ }
|
|
559
|
+
});
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// src/core/chat/channel-protocol.mjs
|
|
2
|
+
// Frame vocabulary + message contracts for the persistent channel-worker
|
|
3
|
+
// protocol (chat-connectivity-design.md §4.4). JSON-lines over stdio: one
|
|
4
|
+
// `\n`-terminated JSON object per frame, stdout protocol-reserved.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately dependency-free (no core imports): channel-worker-child.mjs
|
|
7
|
+
// runs under a scrubbed env and imports THIS module only; plugins vendor their
|
|
8
|
+
// own minimal copies (lib/message.mjs) and never import the host.
|
|
9
|
+
//
|
|
10
|
+
// Message contracts are ported from the pre-1.0 integrations adapter.js:
|
|
11
|
+
// NormalizedMessage = { title: string|null, body: MessageSegment[], severity }
|
|
12
|
+
// MessageSegment = { kind, value, href? }
|
|
13
|
+
// IncomingMessage = { chatId, userId, text, meta } (platform payloads stay
|
|
14
|
+
// in the worker — no `raw` across the pipe: size + secret
|
|
15
|
+
// hygiene, deliberate deviation from the old adapter.js)
|
|
16
|
+
|
|
17
|
+
export const MESSAGE_SEGMENT_KINDS = ['text', 'bold', 'code', 'code_block', 'link', 'markdown'];
|
|
18
|
+
export const SEVERITY_LEVELS = ['info', 'success', 'warning', 'error'];
|
|
19
|
+
|
|
20
|
+
/** Worker connection states (status frames; supervisor adds 'unconfigured' and
|
|
21
|
+
* 'failed' for channels it refuses to spawn / gave up restarting). */
|
|
22
|
+
export const CONNECTION_STATES = ['connecting', 'connected', 'degraded', 'disconnected'];
|
|
23
|
+
|
|
24
|
+
/** Hard cap per protocol line; an oversize frame is a protocol violation and
|
|
25
|
+
* the supervisor kills + restarts the worker. */
|
|
26
|
+
export const MAX_FRAME_BYTES = 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/** Op/send error kinds — same vocabulary as PluginOpError (plugin spec §11). */
|
|
29
|
+
export const ERROR_KINDS = ['auth', 'rate-limit', 'network', 'plugin', 'timeout', 'protocol'];
|
|
30
|
+
|
|
31
|
+
export const HOST_FRAMES = ['hello', 'send', 'webhook', 'config', 'ping', 'shutdown'];
|
|
32
|
+
export const WORKER_FRAMES = ['ready', 'status', 'inbound', 'send-result', 'webhook-result', 'state-delta', 'pong', 'log'];
|
|
33
|
+
|
|
34
|
+
export function isValidSegment(seg) {
|
|
35
|
+
if (!seg || typeof seg !== 'object') return false;
|
|
36
|
+
return MESSAGE_SEGMENT_KINDS.includes(seg.kind) && typeof seg.value === 'string'
|
|
37
|
+
&& (seg.href === undefined || typeof seg.href === 'string');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isValidMessage(msg) {
|
|
41
|
+
if (!msg || typeof msg !== 'object') return false;
|
|
42
|
+
if (msg.title !== null && typeof msg.title !== 'string') return false;
|
|
43
|
+
if (!Array.isArray(msg.body) || !msg.body.every(isValidSegment)) return false;
|
|
44
|
+
return SEVERITY_LEVELS.includes(msg.severity);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isValidIncoming(inc) {
|
|
48
|
+
if (!inc || typeof inc !== 'object') return false;
|
|
49
|
+
return typeof inc.chatId === 'string' && inc.chatId !== ''
|
|
50
|
+
&& typeof inc.userId === 'string'
|
|
51
|
+
&& typeof inc.text === 'string'
|
|
52
|
+
&& (inc.meta === undefined || (inc.meta !== null && typeof inc.meta === 'object'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Structural check of any protocol frame (either direction). Loose by design:
|
|
56
|
+
* unknown types are invalid, but extra fields on known types are tolerated so
|
|
57
|
+
* the protocol can grow additively without breaking old hosts/workers. */
|
|
58
|
+
export function isValidFrame(frame) {
|
|
59
|
+
if (!frame || typeof frame !== 'object' || typeof frame.type !== 'string') return false;
|
|
60
|
+
switch (frame.type) {
|
|
61
|
+
case 'hello':
|
|
62
|
+
return Number.isInteger(frame.apiVersion)
|
|
63
|
+
&& typeof frame.plugin === 'string' && typeof frame.channelId === 'string'
|
|
64
|
+
&& typeof frame.platform === 'string' && typeof frame.module === 'string'
|
|
65
|
+
&& !!frame.config && typeof frame.config === 'object'
|
|
66
|
+
&& !!frame.state && typeof frame.state === 'object';
|
|
67
|
+
case 'send':
|
|
68
|
+
return typeof frame.id === 'string' && typeof frame.chatId === 'string' && isValidMessage(frame.message);
|
|
69
|
+
case 'webhook':
|
|
70
|
+
return typeof frame.id === 'string' && typeof frame.method === 'string'
|
|
71
|
+
&& typeof frame.path === 'string'
|
|
72
|
+
&& !!frame.headers && typeof frame.headers === 'object'
|
|
73
|
+
&& typeof frame.bodyB64 === 'string';
|
|
74
|
+
case 'config':
|
|
75
|
+
return !!frame.config && typeof frame.config === 'object';
|
|
76
|
+
case 'ping':
|
|
77
|
+
case 'pong':
|
|
78
|
+
return typeof frame.id === 'string';
|
|
79
|
+
case 'shutdown':
|
|
80
|
+
return true;
|
|
81
|
+
case 'ready':
|
|
82
|
+
return frame.identity === undefined || frame.identity === null || typeof frame.identity === 'string';
|
|
83
|
+
case 'status':
|
|
84
|
+
return CONNECTION_STATES.includes(frame.state)
|
|
85
|
+
&& (frame.detail === undefined || frame.detail === null || typeof frame.detail === 'string');
|
|
86
|
+
case 'inbound':
|
|
87
|
+
return isValidIncoming(frame.msg);
|
|
88
|
+
case 'send-result':
|
|
89
|
+
return typeof frame.id === 'string' && typeof frame.ok === 'boolean'
|
|
90
|
+
&& (frame.ok || (!!frame.error && typeof frame.error.message === 'string'));
|
|
91
|
+
case 'webhook-result':
|
|
92
|
+
return typeof frame.id === 'string' && Number.isInteger(frame.statusCode);
|
|
93
|
+
case 'state-delta':
|
|
94
|
+
return !!frame.delta && typeof frame.delta === 'object' && !Array.isArray(frame.delta);
|
|
95
|
+
case 'log':
|
|
96
|
+
return typeof frame.msg === 'string';
|
|
97
|
+
default:
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One serialized protocol line (with trailing newline). Throws on oversize. */
|
|
103
|
+
export function encodeFrame(frame) {
|
|
104
|
+
const line = JSON.stringify(frame);
|
|
105
|
+
if (Buffer.byteLength(line, 'utf8') > MAX_FRAME_BYTES) {
|
|
106
|
+
throw new Error(`frame exceeds MAX_FRAME_BYTES (${MAX_FRAME_BYTES})`);
|
|
107
|
+
}
|
|
108
|
+
return `${line}\n`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Parse one protocol line -> frame object, or null on any violation. */
|
|
112
|
+
export function decodeFrame(line) {
|
|
113
|
+
if (typeof line !== 'string' || Buffer.byteLength(line, 'utf8') > MAX_FRAME_BYTES) return null;
|
|
114
|
+
let frame;
|
|
115
|
+
try { frame = JSON.parse(line); } catch { return null; }
|
|
116
|
+
return isValidFrame(frame) ? frame : null;
|
|
117
|
+
}
|