@inetafrica/open-claudia 3.4.6 → 3.4.11
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/CHANGELOG.md +25 -0
- package/channels/kazee/call/access.js +53 -0
- package/channels/kazee/call/audio.js +1 -1
- package/channels/kazee/call/janus-client.js +3 -1
- package/channels/kazee/call/lease.js +70 -0
- package/channels/kazee/call/manager.js +150 -106
- package/channels/kazee/call/opus-codec.js +44 -0
- package/channels/kazee/call/session.js +58 -5
- package/core/scheduler.js +46 -26
- package/docs/PROVIDER_MIGRATION.md +5 -1
- package/package.json +2 -2
- package/test-cursor-removal.js +4 -1
- package/test-kazee-call-audio.js +22 -0
- package/test-kazee-call-janus.js +7 -2
- package/test-kazee-call-manager.js +136 -7
- package/test-kazee-call-session.js +62 -0
- package/test-provider-enforcer.js +29 -6
- package/test-provider-language.js +2 -1
- package/test-provider-scheduler.js +83 -0
|
@@ -73,7 +73,7 @@ const ACK_CHIME_PCM48 = tonePcm48([{ freq: 660, ms: 90 }, { freq: 990, ms: 110 }
|
|
|
73
73
|
const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
|
|
74
74
|
|
|
75
75
|
class KazeeCallSession {
|
|
76
|
-
constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
|
|
76
|
+
constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], onTranscript, onEnded, onMediaReady,
|
|
77
77
|
maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
|
|
78
78
|
JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
79
79
|
this.roomId = Number(roomId);
|
|
@@ -81,18 +81,29 @@ class KazeeCallSession {
|
|
|
81
81
|
this.callerId = String(callerId);
|
|
82
82
|
this.botUserId = String(botUserId);
|
|
83
83
|
this.asCaller = !!asCaller;
|
|
84
|
+
this.managedRoom = managedRoom;
|
|
85
|
+
this.janusTokenExpiresAt = janusTokenExpiresAt;
|
|
86
|
+
this.roomToken = roomToken;
|
|
87
|
+
this.roomTokenExpiresAt = roomTokenExpiresAt;
|
|
88
|
+
this.refreshRoomAccess = refreshRoomAccess;
|
|
89
|
+
this.privateId = null;
|
|
84
90
|
this.greeting = greeting ? String(greeting) : null;
|
|
85
91
|
this._greeted = false;
|
|
86
92
|
this._publisherUp = false;
|
|
87
93
|
this._createdRoom = false;
|
|
88
94
|
this._joined = false;
|
|
89
95
|
this._publishTimer = null;
|
|
90
|
-
this.janus = new JanusClientImpl({ url: janusUrl });
|
|
96
|
+
this.janus = new JanusClientImpl({ url: janusUrl, token: janusToken });
|
|
91
97
|
this.PeerConnectionImpl = PeerConnectionImpl;
|
|
92
98
|
this.SpeechStreamImpl = SpeechStreamImpl;
|
|
93
99
|
this.iceServers = iceServers;
|
|
94
100
|
this.onTranscript = onTranscript || (() => {});
|
|
95
101
|
this.onEnded = onEnded || (() => {});
|
|
102
|
+
this.onMediaReady = onMediaReady || (() => {});
|
|
103
|
+
this._sentRtp = false;
|
|
104
|
+
this._receivedRtp = false;
|
|
105
|
+
this._reportedMediaReady = false;
|
|
106
|
+
this._roomRefresh = null;
|
|
96
107
|
this.synthesize = synthesize;
|
|
97
108
|
// Tests and the jig inject a custom synthesize; only pair the streaming
|
|
98
109
|
// path with the service client it belongs to.
|
|
@@ -123,11 +134,32 @@ class KazeeCallSession {
|
|
|
123
134
|
this.ssrc = Math.floor(Math.random() * 0xffffffff);
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
async _roomAuth(force = false) {
|
|
138
|
+
if (!this.managedRoom) return this.roomToken ? { token: this.roomToken } : {};
|
|
139
|
+
if (force || !this.roomToken || this.roomTokenExpiresAt <= Date.now() + 10000) {
|
|
140
|
+
if (!this.refreshRoomAccess) throw new Error("Call room credentials expired");
|
|
141
|
+
if (!this._roomRefresh) {
|
|
142
|
+
this._roomRefresh = Promise.resolve().then(() => this.refreshRoomAccess()).then(access => {
|
|
143
|
+
if (this.closed) throw new Error("Call ended during credential refresh");
|
|
144
|
+
this.roomToken = access.roomToken;
|
|
145
|
+
this.roomTokenExpiresAt = access.roomTokenExpiresAt;
|
|
146
|
+
if (access.janusToken) this.janus.token = access.janusToken;
|
|
147
|
+
if (access.janusTokenExpiresAt !== undefined) this.janusTokenExpiresAt = access.janusTokenExpiresAt;
|
|
148
|
+
}).finally(() => { this._roomRefresh = null; });
|
|
149
|
+
}
|
|
150
|
+
await this._roomRefresh;
|
|
151
|
+
}
|
|
152
|
+
if (!this.roomToken || !Number.isFinite(this.roomTokenExpiresAt) || this.roomTokenExpiresAt <= Date.now()) throw new Error("Call room credentials unavailable");
|
|
153
|
+
return { token: this.roomToken };
|
|
154
|
+
}
|
|
155
|
+
|
|
126
156
|
async start() {
|
|
127
157
|
await this.janus.connect();
|
|
158
|
+
if (this.closed) { await this.janus.close(); throw new Error("Call ended during setup"); }
|
|
128
159
|
this.publisherHandle = await this.janus.attach();
|
|
160
|
+
if (this.closed) { await this.janus.detach(this.publisherHandle); throw new Error("Call ended during setup"); }
|
|
129
161
|
this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
|
|
130
|
-
if (this.asCaller) {
|
|
162
|
+
if (this.asCaller && !this.managedRoom) {
|
|
131
163
|
// Outbound: the caller side owns room lifecycle, mirroring the web
|
|
132
164
|
// client's create params (audio-only, so no videocodec).
|
|
133
165
|
const created = await this.janus.message(this.publisherHandle, {
|
|
@@ -151,8 +183,9 @@ class KazeeCallSession {
|
|
|
151
183
|
// receiver's retry loop in CallContext.startVideoRoom.
|
|
152
184
|
let joined;
|
|
153
185
|
for (let attempt = 0; ; attempt += 1) {
|
|
186
|
+
if (this.closed) throw new Error("Call ended during setup");
|
|
154
187
|
joined = await this.janus.message(this.publisherHandle, {
|
|
155
|
-
request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
188
|
+
...(await this._roomAuth()), request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
156
189
|
});
|
|
157
190
|
const data = joined?.plugindata?.data || {};
|
|
158
191
|
const noRoom = Number(data.error_code) === 426 || /no such room/i.test(String(data.error || ""));
|
|
@@ -180,6 +213,7 @@ class KazeeCallSession {
|
|
|
180
213
|
if (data.error_code != null) error.code = Number(data.error_code);
|
|
181
214
|
throw error;
|
|
182
215
|
}
|
|
216
|
+
if (data.videoroom === "joined") this.privateId = data.private_id ?? null;
|
|
183
217
|
for (const publisher of data.publishers || []) {
|
|
184
218
|
const feedId = Number(publisher.id);
|
|
185
219
|
if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
|
|
@@ -291,6 +325,7 @@ class KazeeCallSession {
|
|
|
291
325
|
clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
|
|
292
326
|
try {
|
|
293
327
|
const handle = await this.janus.attach();
|
|
328
|
+
if (this.closed) { await this.janus.detach(handle); this.subscribers.delete(feedId); return; }
|
|
294
329
|
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
295
330
|
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
|
|
296
331
|
const sub = {
|
|
@@ -322,7 +357,7 @@ class KazeeCallSession {
|
|
|
322
357
|
if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
|
|
323
358
|
});
|
|
324
359
|
this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
|
|
325
|
-
const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
|
|
360
|
+
const attached = await this.janus.message(handle, { ...(await this._roomAuth()), request: "join", ptype: "subscriber", room: this.roomId, feed: feedId, ...(this.privateId != null ? { private_id: this.privateId } : {}) });
|
|
326
361
|
await this._consumeSubscriberFrame(feedId, attached);
|
|
327
362
|
await sub.stt.connect().then(
|
|
328
363
|
() => clog(`Kazee call STT connected: feed=${feedId}`),
|
|
@@ -470,7 +505,14 @@ class KazeeCallSession {
|
|
|
470
505
|
});
|
|
471
506
|
}
|
|
472
507
|
|
|
508
|
+
_reportMediaReady() {
|
|
509
|
+
if (this.closed || this._reportedMediaReady || !this._sentRtp || !this._receivedRtp) return;
|
|
510
|
+
this._reportedMediaReady = true;
|
|
511
|
+
this.onMediaReady();
|
|
512
|
+
}
|
|
513
|
+
|
|
473
514
|
_writeFrame(frame) {
|
|
515
|
+
if (this.closed || !this.publisher?.track?.writeRtp) return;
|
|
474
516
|
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
475
517
|
const opus = this.codec.encode(frame);
|
|
476
518
|
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
@@ -478,6 +520,8 @@ class KazeeCallSession {
|
|
|
478
520
|
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
479
521
|
}), opus));
|
|
480
522
|
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
523
|
+
this._sentRtp = true;
|
|
524
|
+
this._reportMediaReady();
|
|
481
525
|
}
|
|
482
526
|
|
|
483
527
|
// Writes a short cue into the outbound track. Yields to real speech: the
|
|
@@ -512,6 +556,8 @@ class KazeeCallSession {
|
|
|
512
556
|
if (this.closed || !packet?.payload?.length) return;
|
|
513
557
|
const sub = this.subscribers.get(feedId);
|
|
514
558
|
if (!sub) return;
|
|
559
|
+
this._receivedRtp = true;
|
|
560
|
+
this._reportMediaReady();
|
|
515
561
|
if (!sub.firstRtpAt) {
|
|
516
562
|
sub.firstRtpAt = Date.now();
|
|
517
563
|
clog(`Kazee call first RTP: feed=${feedId}`);
|
|
@@ -712,6 +758,12 @@ class KazeeCallSession {
|
|
|
712
758
|
async _enforceLimits() {
|
|
713
759
|
if (this.closed || this._ending) return;
|
|
714
760
|
const now = Date.now();
|
|
761
|
+
if (this.managedRoom && this.janusTokenExpiresAt !== undefined && this.janusTokenExpiresAt <= now + 60000) {
|
|
762
|
+
if (this.janusTokenExpiresAt <= now) { await this.close("credentials-expired"); return; }
|
|
763
|
+
// Uses the same single in-flight request as subscriber room-token refresh.
|
|
764
|
+
// The next limit tick still closes at expiry if this request stalls.
|
|
765
|
+
this._roomAuth(true).catch(() => {});
|
|
766
|
+
}
|
|
715
767
|
const overMax = now - this._startedAt >= this.maxCallMs;
|
|
716
768
|
// An audible thinking loop means a turn is still being worked on — that is
|
|
717
769
|
// not idleness, or long provider turns would get hung up on mid-answer.
|
|
@@ -743,6 +795,7 @@ class KazeeCallSession {
|
|
|
743
795
|
async close(reason = "ended") {
|
|
744
796
|
if (this.closed) return;
|
|
745
797
|
this.closed = true;
|
|
798
|
+
this._leaseHeartbeat?.close();
|
|
746
799
|
console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
|
|
747
800
|
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
748
801
|
this._clearPublishTimer();
|
package/core/scheduler.js
CHANGED
|
@@ -120,6 +120,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
120
120
|
const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
|
|
121
121
|
const timers = new Map();
|
|
122
122
|
const checksInFlight = new Set();
|
|
123
|
+
const firesInFlight = new Set();
|
|
123
124
|
// Poll a watch's `check`: exit 0 → the watched condition is met, wake the
|
|
124
125
|
// agent; non-zero → still idle, skip silently. Runs in the job's project
|
|
125
126
|
// dir with the process env, output discarded (exit code is the contract),
|
|
@@ -344,18 +345,15 @@ function createSchedulerService(dependencies = {}) {
|
|
|
344
345
|
function markBusy(job) {
|
|
345
346
|
const latest = jobsStore.get(job.id) || job;
|
|
346
347
|
const busyDeferralCount = (latest.busyDeferralCount || 0) + 1;
|
|
347
|
-
const dead = busyDeferralCount >= (latest.maxAttempts || 1);
|
|
348
348
|
const patch = {
|
|
349
|
-
status:
|
|
350
|
-
disabled:
|
|
349
|
+
status: "deferred",
|
|
350
|
+
disabled: false,
|
|
351
351
|
busyDeferralCount,
|
|
352
|
-
nextAttemptAt:
|
|
353
|
-
lastError: dead ? { code: "SCHEDULED_BUSY_EXHAUSTED", message: "Channel remained busy through every bounded deferral" } : null,
|
|
352
|
+
nextAttemptAt: now() + busyDelayMs,
|
|
354
353
|
};
|
|
355
354
|
const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return { ok: false, deferred: !dead, status: updated.status, job: updated };
|
|
355
|
+
armRetry(updated, busyDelayMs);
|
|
356
|
+
return { ok: false, deferred: true, status: updated.status, job: updated };
|
|
359
357
|
}
|
|
360
358
|
|
|
361
359
|
async function settleSuccess(job, result, target) {
|
|
@@ -396,6 +394,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
396
394
|
attemptCount: 0,
|
|
397
395
|
busyDeferralCount: 0,
|
|
398
396
|
nextAttemptAt: null,
|
|
397
|
+
watchTriggeredOccurrenceId: null,
|
|
398
|
+
watchTriggeredAt: null,
|
|
399
|
+
watchExpired: false,
|
|
400
|
+
advisoryOccurrenceId: null,
|
|
399
401
|
});
|
|
400
402
|
if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
|
|
401
403
|
return reset
|
|
@@ -404,6 +406,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
404
406
|
}
|
|
405
407
|
|
|
406
408
|
async function fireJob(idOrJob, options = {}) {
|
|
409
|
+
const id = typeof idOrJob === "string" ? idOrJob : idOrJob?.id;
|
|
410
|
+
if (firesInFlight.has(id)) return { ok: false, status: "running", skipped: true, inFlight: true };
|
|
411
|
+
firesInFlight.add(id);
|
|
412
|
+
try { return await executeJob(idOrJob, options); }
|
|
413
|
+
finally { firesInFlight.delete(id); }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function executeJob(idOrJob, options = {}) {
|
|
407
417
|
const requested = typeof idOrJob === "string" ? jobsStore.get(idOrJob) : idOrJob;
|
|
408
418
|
let job = requested?.id && jobsStore.get(requested.id) ? jobsStore.get(requested.id) : requested;
|
|
409
419
|
if (!job) return { ok: false, status: "missing" };
|
|
@@ -423,7 +433,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
423
433
|
// runs the check and only wakes the agent when it exits 0. A non-zero exit
|
|
424
434
|
// is the normal idle case: skip silently, mark nothing, leave the job
|
|
425
435
|
// pending so the next tick polls again. Never marks failure; never chats.
|
|
426
|
-
if (job.check) {
|
|
436
|
+
if (job.check && job.watchTriggeredOccurrenceId !== job.occurrenceId) {
|
|
427
437
|
if (!watchesEnabled()) {
|
|
428
438
|
logWatch(job, "skipped (watches disabled globally)");
|
|
429
439
|
return { ok: false, status: "watch-disabled", skipped: true };
|
|
@@ -443,27 +453,30 @@ function createSchedulerService(dependencies = {}) {
|
|
|
443
453
|
logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
|
|
444
454
|
return { ok: false, status: "watch-idle", skipped: true };
|
|
445
455
|
}
|
|
446
|
-
logWatch(job, `expired without firing (check exit ${check ? check.code : "?"}) — waking agent`);
|
|
447
456
|
// Expiry ends the watch's window: one-shot removal even for --repeat
|
|
448
457
|
// (the woken agent re-arms if still wanted). Persisted so a retry of a
|
|
449
458
|
// failed expiry fire keeps the same semantics across restarts.
|
|
450
|
-
|
|
451
|
-
const marked = jobsStore.update(job.id, { repeat: false, watchExpired: true });
|
|
452
|
-
if (marked) job = marked;
|
|
453
|
-
else { job.repeat = false; job.watchExpired = true; }
|
|
454
|
-
} catch (_) { job.repeat = false; job.watchExpired = true; }
|
|
459
|
+
job = { ...job, repeat: false, watchExpired: true };
|
|
455
460
|
} else {
|
|
456
|
-
|
|
457
|
-
if (job.watchExpired) {
|
|
458
|
-
// A prior expiry fire failed and the condition came true before the
|
|
459
|
-
// retry: report it as a real trigger, not an expiry.
|
|
460
|
-
try {
|
|
461
|
-
const cleared = jobsStore.update(job.id, { watchExpired: false });
|
|
462
|
-
if (cleared) job = cleared;
|
|
463
|
-
else job.watchExpired = false;
|
|
464
|
-
} catch (_) { job.watchExpired = false; }
|
|
465
|
-
}
|
|
461
|
+
job = { ...job, watchExpired: false };
|
|
466
462
|
}
|
|
463
|
+
// Latch the condition before admission or notification. Delivery retries
|
|
464
|
+
// and restarts must not run the check again or change an expiry's meaning.
|
|
465
|
+
try {
|
|
466
|
+
const marked = jobsStore.update(job.id, {
|
|
467
|
+
status: "deferred",
|
|
468
|
+
nextAttemptAt: now(),
|
|
469
|
+
repeat: job.repeat,
|
|
470
|
+
watchExpired: job.watchExpired,
|
|
471
|
+
watchTriggeredOccurrenceId: job.occurrenceId,
|
|
472
|
+
watchTriggeredAt: now(),
|
|
473
|
+
});
|
|
474
|
+
if (!marked) throw new Error("watch trigger could not be persisted");
|
|
475
|
+
job = marked;
|
|
476
|
+
} catch (error) {
|
|
477
|
+
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
478
|
+
}
|
|
479
|
+
logWatch(job, job.watchExpired ? "expired without firing — delivery queued" : "triggered (check passed) — delivery queued");
|
|
467
480
|
}
|
|
468
481
|
|
|
469
482
|
const adapter = resolveAdapter(job);
|
|
@@ -557,7 +570,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
557
570
|
: target.fallbackFrom
|
|
558
571
|
? `\n⚠️ ${target.fallbackFrom} is unavailable — running on fallback ${target.provider}.`
|
|
559
572
|
: "";
|
|
560
|
-
|
|
573
|
+
if (job.advisoryOccurrenceId !== job.occurrenceId) {
|
|
574
|
+
// At-most-once advisory: persist before sending so a restart cannot
|
|
575
|
+
// repeat it. A crash between these steps may omit this optional notice.
|
|
576
|
+
try {
|
|
577
|
+
if (!jobsStore.update(job.id, { advisoryOccurrenceId: job.occurrenceId })) throw new Error("advisory marker could not be persisted");
|
|
578
|
+
await send(`${advisory}${providerNote}`);
|
|
579
|
+
} catch (_) { /* advisory only */ }
|
|
580
|
+
}
|
|
561
581
|
let result;
|
|
562
582
|
try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
|
|
563
583
|
catch (error) {
|
|
@@ -64,4 +64,8 @@ The pointer, provider-tagged history lineage, and tuple-scoped usage checkpoint
|
|
|
64
64
|
|
|
65
65
|
Scheduled work uses a versioned `{schemaVersion, jobs, archived}` document. Every runnable occurrence stores its canonical user, channel, project, provider, provider-settings snapshot, native session lineage, origin run, and stable occurrence ID. A fresh foreground turn can create a job before its native session is known; terminal session persistence binds that job by origin run ID. Compaction descendants are followed only when recorded lineage proves the relationship, and an older scheduled branch cannot replace an unrelated live session pointer.
|
|
66
66
|
|
|
67
|
-
Wakeups and crons wait for terminal provider completion, transcript/usage/session persistence, and required delivery. A one-shot receives a durable success marker before deletion. Busy
|
|
67
|
+
Wakeups and crons wait for terminal provider completion, transcript/usage/session persistence, and required delivery. A one-shot receives a durable success marker before deletion. Busy occurrences retain their occurrence ID across restart and wait until the chat is idle, without consuming execution attempts or expiring under the missed-fire grace window. Genuine execution failures still have bounded retries; exhaustion and wakeups never admitted within the missed-fire grace window remain visible as disabled dead letters.
|
|
68
|
+
|
|
69
|
+
A watch persists its trigger (or expiry) before delivery and does not poll its check again while that occurrence is pending. Repeat watches resume polling only after successful delivery advances the occurrence. An in-process guard prevents overlapping callbacks from starting the same occurrence, even when a run lasts longer than its retry delay. Advisory notifications are marked before sending and attempted at most once per occurrence across retries/restarts; a crash between marking and sending can omit this optional notice. An interrupted provider run may still retry with the same occurrence ID if no durable success marker exists; this is not an exactly-once guarantee for external side effects. Previously dead-lettered jobs are not automatically revived because their queued actions may now be stale.
|
|
70
|
+
|
|
71
|
+
Provider fallback is empty by default. When `PROVIDER_FALLBACKS` is explicitly configured, a fallback starts a fresh provider session with an archived provider-neutral brief and never receives another provider's native session ID. Legacy `crons.json` remains byte-for-byte untouched after its ambiguous records are copied into the disabled archive.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.11",
|
|
4
4
|
"description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"dependencies": {
|
|
151
151
|
"node-cron": "^4.2.1",
|
|
152
152
|
"node-telegram-bot-api": "^0.67.0",
|
|
153
|
-
"opusscript": "
|
|
153
|
+
"opusscript": "0.1.1",
|
|
154
154
|
"socket.io-client": "^4.7.5",
|
|
155
155
|
"werift": "^0.23.0",
|
|
156
156
|
"ws": "^8.21.1"
|
package/test-cursor-removal.js
CHANGED
|
@@ -116,11 +116,14 @@ function migrationOrderingAndRollbackProbe(migrationApi) {
|
|
|
116
116
|
OPEN_CLAUDIA_TEST: "1",
|
|
117
117
|
},
|
|
118
118
|
encoding: "utf8",
|
|
119
|
-
|
|
119
|
+
// This probes durable migration ordering, not a latency contract.
|
|
120
|
+
// Shared CI disks can take tens of seconds for the required fsyncs.
|
|
121
|
+
timeout: 120000,
|
|
120
122
|
});
|
|
121
123
|
fs.mkdirSync(path.join(temp, "home"), { recursive: true });
|
|
122
124
|
fs.mkdirSync(path.join(temp, "workspace"), { recursive: true });
|
|
123
125
|
const first = runUpgrade();
|
|
126
|
+
assert.ifError(first.error);
|
|
124
127
|
assert.strictEqual(first.status, 0, first.stderr || first.stdout);
|
|
125
128
|
const upgraded = JSON.parse(first.stdout);
|
|
126
129
|
assert.strictEqual(upgraded.state.schemaVersion, 3);
|
package/test-kazee-call-audio.js
CHANGED
|
@@ -24,8 +24,30 @@ const packet = codec.encode(source);
|
|
|
24
24
|
const decoded = codec.decode(packet);
|
|
25
25
|
assert.strictEqual(decoded.length, source.length, "Opus round trip preserves the 20ms frame size");
|
|
26
26
|
assert(rmsDbfs(decoded) > -20, "decoded tone has useful signal energy");
|
|
27
|
+
const OriginalOpus=require('opusscript');
|
|
28
|
+
const original=new OriginalOpus(48000,1,OriginalOpus.Application.VOIP);
|
|
29
|
+
try {
|
|
30
|
+
assert(rmsDbfs(Buffer.from(original.decode(packet)))>-20,'original package decodes new wrapper');
|
|
31
|
+
assert(rmsDbfs(codec.decode(original.encode(source,WEBRTC_SAMPLES)))>-20,'new wrapper decodes original package');
|
|
32
|
+
} finally {original.delete();}
|
|
27
33
|
codec.close();
|
|
28
34
|
|
|
35
|
+
// Force shared WASM growth after earlier instances cached their inputs/outputs.
|
|
36
|
+
const many = [];
|
|
37
|
+
try {
|
|
38
|
+
for (let i=0;i<100;i++) many.push(new OpusAudioCodec());
|
|
39
|
+
for (const active of many) {
|
|
40
|
+
const encoded=active.encode(source);
|
|
41
|
+
const audio=active.decode(encoded);
|
|
42
|
+
assert.equal(audio.length,source.length);
|
|
43
|
+
assert(rmsDbfs(audio)>-20);
|
|
44
|
+
}
|
|
45
|
+
// Keep the first codec alive while the others free/reallocate memory.
|
|
46
|
+
for (const active of many.slice(1)) active.close();
|
|
47
|
+
for (let i=0;i<100;i++) {const next=new OpusAudioCodec(); next.close();}
|
|
48
|
+
assert.equal(many[0].decode(many[0].encode(source)).length,source.length);
|
|
49
|
+
} finally {for(const active of many)active.close();}
|
|
50
|
+
|
|
29
51
|
const pcm16 = downsample48To16(source);
|
|
30
52
|
assert.strictEqual(pcm16.length, 320 * 2, "48k→16k produces 320 samples for a 20ms frame");
|
|
31
53
|
const pcm48 = upsampleTo48(pcm16, 16000);
|
package/test-kazee-call-janus.js
CHANGED
|
@@ -10,10 +10,12 @@ async function main() {
|
|
|
10
10
|
await new Promise((resolve) => server.once("listening", resolve));
|
|
11
11
|
const port = server.address().port;
|
|
12
12
|
const seen = [];
|
|
13
|
+
let expectedToken = "root-test-token";
|
|
13
14
|
|
|
14
15
|
server.on("connection", (socket) => socket.on("message", (raw) => {
|
|
15
16
|
const frame = JSON.parse(raw.toString());
|
|
16
17
|
seen.push(frame.janus);
|
|
18
|
+
assert.strictEqual(frame.token, expectedToken, "root authentication is present on every actual WebSocket request");
|
|
17
19
|
if (frame.janus === "create") {
|
|
18
20
|
socket.send(JSON.stringify({ janus: "success", transaction: frame.transaction, data: { id: 42 } }));
|
|
19
21
|
} else if (frame.janus === "attach") {
|
|
@@ -32,7 +34,7 @@ async function main() {
|
|
|
32
34
|
}
|
|
33
35
|
}));
|
|
34
36
|
|
|
35
|
-
const client = new JanusClient({ url: `ws://127.0.0.1:${port}`, keepaliveMs: 60000 });
|
|
37
|
+
const client = new JanusClient({ url: `ws://127.0.0.1:${port}`, keepaliveMs: 60000, token: "root-test-token" });
|
|
36
38
|
await client.connect();
|
|
37
39
|
assert.strictEqual(client.sessionId, 42);
|
|
38
40
|
const handle = await client.attach();
|
|
@@ -42,9 +44,12 @@ async function main() {
|
|
|
42
44
|
const joined = await client.message(handle, { request: "join", ptype: "publisher", room: 7 });
|
|
43
45
|
assert.strictEqual(joined.plugindata.data.videoroom, "joined", "ack is ignored until the plugin event arrives");
|
|
44
46
|
assert.strictEqual(handleEvent?.sender, 84, "plugin event is also delivered to the handle listener");
|
|
47
|
+
expectedToken = client.token = "renewed-root-token";
|
|
48
|
+
await client.message(handle, { request: "configure", audio: true });
|
|
49
|
+
assert.strictEqual(client.sessionId, 42, "renewal retains the same live session");
|
|
45
50
|
await client.close();
|
|
46
51
|
await new Promise((resolve) => server.close(resolve));
|
|
47
|
-
assert.deepStrictEqual(seen
|
|
52
|
+
assert.deepStrictEqual(seen, ["create", "attach", "message", "message", "destroy"]);
|
|
48
53
|
console.log("kazee call Janus control plane: ok");
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -27,7 +27,7 @@ function fixture() {
|
|
|
27
27
|
await f.manager.incoming({ fromUserId: "owner-1", fromUserName: "Owner", chatId: "chat", roomId: 12, callType: "audio" });
|
|
28
28
|
// Ringing must go out first (caller UI feedback), then the accept.
|
|
29
29
|
assert.strictEqual(f.events[0].name, "call:ringing");
|
|
30
|
-
assert.deepStrictEqual(f.events[0].payload, { toUserId: "owner-1", chatId: "chat" });
|
|
30
|
+
assert.deepStrictEqual(f.events[0].payload, { toUserId: "owner-1", chatId: "chat", roomId: 12 });
|
|
31
31
|
assert.strictEqual(f.events[1].name, "call:accept");
|
|
32
32
|
f.manager.session.options.onTranscript("What is on my agenda?");
|
|
33
33
|
assert.strictEqual(f.inbound[0].payload.raw.ephemeralAudio, true);
|
|
@@ -49,12 +49,11 @@ function fixture() {
|
|
|
49
49
|
assert.ok(outSession.options.greeting);
|
|
50
50
|
assert.strictEqual(outSession.started, true);
|
|
51
51
|
const invite = f.events.find((e) => e.name === "call:invite");
|
|
52
|
-
assert.deepStrictEqual(invite.payload, { toUserId: "owner-1", callType: "audio", chatId: "chat-2", roomId: res.roomId });
|
|
52
|
+
assert.deepStrictEqual(invite.payload, { toUserId: "owner-1", callType: "audio", chatId: "chat-2", roomId: res.roomId, callAttemptId: res.callAttemptId });
|
|
53
53
|
assert.ok(f.manager._ringTimer);
|
|
54
|
-
// A
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
assert.ok(f.manager.session, "ended-while-ringing is ignored");
|
|
54
|
+
// A stale previous-attempt termination must not affect the active ring.
|
|
55
|
+
await f.manager.ended({ chatId: "chat-2", callAttemptId: "previous-attempt-123456" });
|
|
56
|
+
assert.ok(f.manager.session, "stale ended is ignored");
|
|
58
57
|
f.manager.accepted({ chatId: "chat-2" });
|
|
59
58
|
assert.strictEqual(f.manager._ringTimer, null);
|
|
60
59
|
// Outbound transcripts dispatch with the remote human as the "from" side.
|
|
@@ -80,7 +79,137 @@ function fixture() {
|
|
|
80
79
|
f.manager._clearRingTimer();
|
|
81
80
|
await f.manager._ringTimeout("owner-1", "chat-3", res2.roomId);
|
|
82
81
|
const end = f.events.find((e) => e.name === "call:end");
|
|
83
|
-
assert.deepStrictEqual(end.payload, { toUserId: "owner-1", chatId: "chat-3" });
|
|
82
|
+
assert.deepStrictEqual(end.payload, { toUserId: "owner-1", chatId: "chat-3", roomId: res2.roomId, callAttemptId: res2.callAttemptId });
|
|
84
83
|
assert.strictEqual(f.manager.session, null);
|
|
84
|
+
// Managed allocation is completed before Session.start, and credentials stay off relays.
|
|
85
|
+
f = fixture();
|
|
86
|
+
const { EventEmitter } = require("events");
|
|
87
|
+
const socket = new EventEmitter();
|
|
88
|
+
socket.connected = true;
|
|
89
|
+
const emitted = [];
|
|
90
|
+
let replyPlacement = {nodeId: "secondary", server: "wss://assigned/janus"};
|
|
91
|
+
const originalEmit = socket.emit.bind(socket);
|
|
92
|
+
socket.emit = (name, payload) => {
|
|
93
|
+
emitted.push({ name, payload });
|
|
94
|
+
if (["call:allocate", "call:room-access"].includes(name)) {
|
|
95
|
+
setImmediate(() => originalEmit(`${name}-result`, { requestId: payload.requestId, ok: true, protocolVersion: 3,
|
|
96
|
+
callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", roomId: 100, chatId: "managed-chat",
|
|
97
|
+
deviceId: payload.deviceId, generation: 0, ...replyPlacement, janusToken: "root-token", roomToken: "room-token", roomTokenExpiresAt: Date.now() + 300000, leaseExpiresAt: Date.now() + 120000 }));
|
|
98
|
+
}
|
|
99
|
+
return originalEmit(name, payload);
|
|
100
|
+
};
|
|
101
|
+
f.manager.adapter._socket = socket;
|
|
102
|
+
f.manager.adapter._request = async () => ({ config: { roomAdmissionEnabled: true, janusServer: "wss://managed/janus", iceServers: [{ urls: ["turn:relay:3478", "turn:relay:3478?transport=tcp"], username: "test", credential: "test" }] } });
|
|
103
|
+
await f.manager.outgoing({ chatId: "managed-chat" });
|
|
104
|
+
assert.strictEqual(f.manager.session.options.managedRoom, true);
|
|
105
|
+
assert.strictEqual(f.manager.session.options.janusToken, "root-token");
|
|
106
|
+
assert.strictEqual(f.manager.session.options.roomToken, "room-token");
|
|
107
|
+
assert.strictEqual(f.manager.session.options.janusUrl, "wss://assigned/janus");
|
|
108
|
+
assert.deepStrictEqual(f.manager.session.options.iceServers[0].urls, ["turn:relay:3478"]);
|
|
109
|
+
const refreshed = await f.manager.session.options.refreshRoomAccess();
|
|
110
|
+
assert.strictEqual(refreshed.server, "wss://assigned/janus");
|
|
111
|
+
replyPlacement = {nodeId: "secondary", server: "wss://other/janus"};
|
|
112
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
113
|
+
replyPlacement = {nodeId: "secondary", server: "wss://assigned/janus", generation: 1};
|
|
114
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
115
|
+
replyPlacement = {nodeId: "other", server: "wss://assigned/janus"};
|
|
116
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
117
|
+
replyPlacement = {nodeId: "secondary", server: "wss://assigned/janus"};
|
|
118
|
+
assert.strictEqual(f.manager.session.callIdentity.deviceId, f.manager.deviceId);
|
|
119
|
+
const managedInvite = emitted.find(event => event.name === "call:invite").payload;
|
|
120
|
+
assert.strictEqual(managedInvite.callAttemptId, "managed-attempt-123456789");
|
|
121
|
+
assert.strictEqual(managedInvite.deviceId, f.manager.deviceId);
|
|
122
|
+
assert.strictEqual(managedInvite.generation, 0);
|
|
123
|
+
assert.strictEqual(managedInvite.janusToken, undefined);
|
|
124
|
+
assert.strictEqual(managedInvite.roomToken, undefined);
|
|
125
|
+
f.manager.accepted({ chatId: "managed-chat", callAttemptId: "stale-attempt-123456789" });
|
|
126
|
+
assert.ok(f.manager._ringTimer, "stale acceptance must not stop ring deadline");
|
|
127
|
+
const managedSession = f.manager.session;
|
|
128
|
+
managedSession.options.onMediaReady();
|
|
129
|
+
assert.strictEqual(emitted.filter(event => event.name === "call:heartbeat").length, 0, "unaccepted media cannot renew a lease");
|
|
130
|
+
f.manager.accepted({ chatId: "managed-chat", callAttemptId: managedInvite.callAttemptId });
|
|
131
|
+
const heartbeat = emitted.find(event => event.name === "call:heartbeat").payload;
|
|
132
|
+
assert.strictEqual(heartbeat.mediaReady, true);
|
|
133
|
+
assert.strictEqual(heartbeat.deviceId, f.manager.deviceId);
|
|
134
|
+
assert.strictEqual(heartbeat.callAttemptId, managedInvite.callAttemptId);
|
|
135
|
+
assert.strictEqual(heartbeat.roomToken, undefined);
|
|
136
|
+
originalEmit("call:heartbeat-result", { requestId: heartbeat.requestId, ok: true, leaseExpiresAt: Date.now() + 120000 });
|
|
137
|
+
|
|
138
|
+
await f.manager.ended({ chatId: "managed-chat", roomId: 100, callAttemptId: managedInvite.callAttemptId });
|
|
139
|
+
assert.strictEqual(f.manager.session, null, "authoritative end also ends an unanswered modern call");
|
|
140
|
+
assert.strictEqual(socket.listenerCount("call:allocate-result"), 0);
|
|
141
|
+
assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0, "ended manager session releases lease listener");
|
|
142
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "audio", callAttemptId: managedInvite.callAttemptId, organizationId: "tenant-a" });
|
|
143
|
+
const incomingManaged = f.manager.session;
|
|
144
|
+
const managedAccept = emitted.filter(event => event.name === "call:accept").at(-1).payload;
|
|
145
|
+
assert.strictEqual(managedAccept.deviceId, f.manager.deviceId);
|
|
146
|
+
assert.strictEqual(managedAccept.generation, 0);
|
|
147
|
+
const previousBeats = emitted.filter(event => event.name === "call:heartbeat").length;
|
|
148
|
+
assert.strictEqual(incomingManaged._accepted, true);
|
|
149
|
+
incomingManaged.options.onMediaReady();
|
|
150
|
+
assert.strictEqual(emitted.filter(event => event.name === "call:heartbeat").length, previousBeats + 1);
|
|
151
|
+
const deniedBeat = emitted.filter(event => event.name === "call:heartbeat").at(-1).payload;
|
|
152
|
+
originalEmit("call:heartbeat-result", { requestId: deniedBeat.requestId, ok: false });
|
|
153
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
154
|
+
assert.strictEqual(f.manager.session, null, "explicit lease denial closes the call");
|
|
155
|
+
assert.strictEqual(incomingManaged.closed, "lease-denied");
|
|
156
|
+
assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0);
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
f = fixture();
|
|
160
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "video", callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", generation: 7 });
|
|
161
|
+
assert.strictEqual(f.events[0].payload.generation, 7, "pre-access decline preserves recipient reinvite generation");
|
|
162
|
+
assert.strictEqual(f.events[0].payload.deviceId, f.manager.deviceId);
|
|
163
|
+
|
|
164
|
+
f = fixture();
|
|
165
|
+
await f.manager.outgoing({ chatId: "local-end" });
|
|
166
|
+
const localSession = f.manager.session;
|
|
167
|
+
localSession.options.onEnded("max-duration");
|
|
168
|
+
localSession.options.onEnded("max-duration");
|
|
169
|
+
assert.strictEqual(f.events.filter(event => event.name === "call:end").length, 1);
|
|
170
|
+
assert.strictEqual(f.events.at(-1).payload.callAttemptId, localSession.callIdentity.callAttemptId);
|
|
171
|
+
assert.strictEqual(f.manager.session, null);
|
|
172
|
+
|
|
173
|
+
// Stop during configuration cancels setup; a late fetch cannot start media.
|
|
174
|
+
f = fixture();
|
|
175
|
+
let finishConfig;
|
|
176
|
+
f.manager.adapter._request = () => new Promise(resolve => { finishConfig = resolve; });
|
|
177
|
+
const incoming = f.manager.incoming({ fromUserId: "owner-1", chatId: "late", roomId: 80, callType: "audio" });
|
|
178
|
+
await f.manager.stop();
|
|
179
|
+
finishConfig({ config: { roomAdmissionEnabled: false, iceServers: [{ urls: "turn:relay:3478" }] } });
|
|
180
|
+
await incoming;
|
|
181
|
+
assert.strictEqual(f.manager.session, null);
|
|
182
|
+
assert.ok(!f.events.some(event => event.name === "call:accept"));
|
|
183
|
+
|
|
184
|
+
// Configuration errors never fall back to unauthenticated STUN-only media.
|
|
185
|
+
f = fixture();
|
|
186
|
+
f.manager.adapter._request = async () => { throw new Error("unavailable"); };
|
|
187
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "fail", roomId: 81, callType: "audio" });
|
|
188
|
+
assert.strictEqual(f.manager.session, null);
|
|
189
|
+
assert.strictEqual(f.events.at(-1).payload.reason, "connection_failed");
|
|
190
|
+
await assert.rejects(() => f.manager.outgoing({ chatId: "chat", toUserId: "stranger" }), /owner/);
|
|
191
|
+
// Correlated access requests reject expiry/mismatch and detach on cancellation.
|
|
192
|
+
const { requestRoomAccess } = require("./channels/kazee/call/access");
|
|
193
|
+
const accessSocket = new EventEmitter();
|
|
194
|
+
accessSocket.connected = true;
|
|
195
|
+
let requested;
|
|
196
|
+
accessSocket.on("call:allocate", payload => { requested = payload; });
|
|
197
|
+
const accessPromise = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
|
|
198
|
+
accessSocket.emit("call:allocate-result", { requestId: "other-request", ok: false });
|
|
199
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 1);
|
|
200
|
+
accessSocket.emit("call:allocate-result", { requestId: requested.requestId, ok: true, protocolVersion: 3, callAttemptId: "attempt-1234567890", organizationId: "tenant-a", roomId: 20, chatId: "chat", deviceId: "different-device", generation: 0, janusToken: "t", roomToken: "t", roomTokenExpiresAt: Date.now() + 300000 });
|
|
201
|
+
await assert.rejects(accessPromise, /Invalid call room/);
|
|
202
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
203
|
+
for (const server of ["http://assigned/janus", "wss://user:password@assigned/janus", "wss://assigned/janus#fragment", 123]) {
|
|
204
|
+
const invalid = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
|
|
205
|
+
accessSocket.emit("call:allocate-result", { requestId: requested.requestId, ok: true, protocolVersion: 3, callAttemptId: "attempt-1234567890", organizationId: "tenant-a", roomId: 20, chatId: "chat", deviceId: "device-1234567890", generation: 0, server, janusToken: "t", roomToken: "t", roomTokenExpiresAt: Date.now() + 300000 });
|
|
206
|
+
await assert.rejects(invalid, /Invalid call room/);
|
|
207
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
208
|
+
}
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const cancelledAccess = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" }, { signal: controller.signal });
|
|
211
|
+
controller.abort();
|
|
212
|
+
await assert.rejects(cancelledAccess, /cancelled/);
|
|
213
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
85
214
|
console.log("kazee call manager: ok");
|
|
86
215
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|