@memberjunction/livekit-room-server 6.2.0-edge.1 → 6.2.0-edge.3
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 +85 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/livekit-agent-room-coordinator.d.ts +105 -8
- package/dist/livekit-agent-room-coordinator.d.ts.map +1 -1
- package/dist/livekit-agent-room-coordinator.js +125 -25
- package/dist/livekit-agent-room-coordinator.js.map +1 -1
- package/dist/livekit-egress-service.d.ts +7 -0
- package/dist/livekit-egress-service.d.ts.map +1 -1
- package/dist/livekit-egress-service.js +11 -0
- package/dist/livekit-egress-service.js.map +1 -1
- package/dist/livekit-native-module.d.ts +18 -0
- package/dist/livekit-native-module.d.ts.map +1 -0
- package/dist/livekit-native-module.js +20 -0
- package/dist/livekit-native-module.js.map +1 -0
- package/dist/livekit-sip-service.d.ts +141 -0
- package/dist/livekit-sip-service.d.ts.map +1 -0
- package/dist/livekit-sip-service.js +169 -0
- package/dist/livekit-sip-service.js.map +1 -0
- package/dist/livekit-token-service.d.ts +6 -0
- package/dist/livekit-token-service.d.ts.map +1 -1
- package/dist/livekit-token-service.js +8 -0
- package/dist/livekit-token-service.js.map +1 -1
- package/dist/livekit-webhook.d.ts +65 -0
- package/dist/livekit-webhook.d.ts.map +1 -0
- package/dist/livekit-webhook.js +65 -0
- package/dist/livekit-webhook.js.map +1 -0
- package/dist/meeting-dial-in-service.d.ts +75 -0
- package/dist/meeting-dial-in-service.d.ts.map +1 -0
- package/dist/meeting-dial-in-service.js +175 -0
- package/dist/meeting-dial-in-service.js.map +1 -0
- package/dist/room-audio/audio-decoder.d.ts +40 -0
- package/dist/room-audio/audio-decoder.d.ts.map +1 -0
- package/dist/room-audio/audio-decoder.js +205 -0
- package/dist/room-audio/audio-decoder.js.map +1 -0
- package/dist/room-audio/comfort-tone.d.ts +23 -0
- package/dist/room-audio/comfort-tone.d.ts.map +1 -0
- package/dist/room-audio/comfort-tone.js +70 -0
- package/dist/room-audio/comfort-tone.js.map +1 -0
- package/dist/room-audio/pcm-frame-pump.d.ts +134 -0
- package/dist/room-audio/pcm-frame-pump.d.ts.map +1 -0
- package/dist/room-audio/pcm-frame-pump.js +253 -0
- package/dist/room-audio/pcm-frame-pump.js.map +1 -0
- package/dist/room-audio/room-audio-decode-cache.d.ts +37 -0
- package/dist/room-audio/room-audio-decode-cache.d.ts.map +1 -0
- package/dist/room-audio/room-audio-decode-cache.js +75 -0
- package/dist/room-audio/room-audio-decode-cache.js.map +1 -0
- package/dist/room-audio/room-audio-player.d.ts +178 -0
- package/dist/room-audio/room-audio-player.d.ts.map +1 -0
- package/dist/room-audio/room-audio-player.js +404 -0
- package/dist/room-audio/room-audio-player.js.map +1 -0
- package/dist/room-audio/room-audio-sources.d.ts +47 -0
- package/dist/room-audio/room-audio-sources.d.ts.map +1 -0
- package/dist/room-audio/room-audio-sources.js +116 -0
- package/dist/room-audio/room-audio-sources.js.map +1 -0
- package/dist/room-audio/room-speech-synthesizer.d.ts +55 -0
- package/dist/room-audio/room-speech-synthesizer.d.ts.map +1 -0
- package/dist/room-audio/room-speech-synthesizer.js +56 -0
- package/dist/room-audio/room-speech-synthesizer.js.map +1 -0
- package/dist/room-authorization.d.ts +58 -0
- package/dist/room-authorization.d.ts.map +1 -0
- package/dist/room-authorization.js +212 -0
- package/dist/room-authorization.js.map +1 -0
- package/dist/room-handoff/handoff-offer-registry.d.ts +137 -0
- package/dist/room-handoff/handoff-offer-registry.d.ts.map +1 -0
- package/dist/room-handoff/handoff-offer-registry.js +521 -0
- package/dist/room-handoff/handoff-offer-registry.js.map +1 -0
- package/dist/room-handoff/handoff-types.d.ts +170 -0
- package/dist/room-handoff/handoff-types.d.ts.map +1 -0
- package/dist/room-handoff/handoff-types.js +16 -0
- package/dist/room-handoff/handoff-types.js.map +1 -0
- package/dist/room-handoff/room-handoff-engine.d.ts +141 -0
- package/dist/room-handoff/room-handoff-engine.d.ts.map +1 -0
- package/dist/room-handoff/room-handoff-engine.js +530 -0
- package/dist/room-handoff/room-handoff-engine.js.map +1 -0
- package/package.json +14 -9
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Durable registry of handoff offers backed by `MJ: Interaction Offers`.
|
|
3
|
+
*
|
|
4
|
+
* An offer is a short-lived question put to one person: "a caller is waiting, will you take the conversation?".
|
|
5
|
+
* It lives in `MJ: Interaction Offers` from the moment the AI asks until it is accepted, declined, expired or cancelled,
|
|
6
|
+
* and is kept so a console that was open when it resolved can still show what happened.
|
|
7
|
+
*
|
|
8
|
+
* **Durable and cluster-safe.** Offers are stored in the database (`MJ: Interaction Offers`). Accept and decline
|
|
9
|
+
* execute a compare-and-set on `Status = 'Pending'` inside `RunInEntityTransaction` so multiple server instances
|
|
10
|
+
* cannot both accept or race. Expired offers are updated on read and through a periodic sweep. Live changes are
|
|
11
|
+
* published locally and across instances via cluster pub/sub.
|
|
12
|
+
*
|
|
13
|
+
* An accept or decline is authorised by comparing the offer's target user with the caller, and a mismatch answers
|
|
14
|
+
* exactly as a missing offer does: someone who does not own an offer cannot learn that it exists.
|
|
15
|
+
*
|
|
16
|
+
* @module @memberjunction/livekit-room-server
|
|
17
|
+
*/
|
|
18
|
+
import { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
19
|
+
import { BaseSingleton } from '@memberjunction/global';
|
|
20
|
+
import type { HandoffMode, HandoffOfferStatus, HandoffOfferView } from './handoff-types.js';
|
|
21
|
+
/** How long a person has to accept an offer before it expires. */
|
|
22
|
+
export declare const HANDOFF_OFFER_TIMEOUT_MS = 45000;
|
|
23
|
+
/** How long a resolved offer stays listed (so a console that was open still sees what happened). */
|
|
24
|
+
export declare const HANDOFF_OFFER_RETENTION_MS: number;
|
|
25
|
+
/** The most pending offers one person can have at once; a flood of offers is refused rather than queued. */
|
|
26
|
+
export declare const MAX_PENDING_OFFERS_PER_USER = 10;
|
|
27
|
+
/** What the registry stores for one offer. */
|
|
28
|
+
export interface HandoffOfferRecord {
|
|
29
|
+
OfferID: string;
|
|
30
|
+
RoomName: string;
|
|
31
|
+
/** The person the offer was made to. Server-side only. */
|
|
32
|
+
TargetUserID: string;
|
|
33
|
+
Mode: HandoffMode;
|
|
34
|
+
Summary: string;
|
|
35
|
+
CallerLabel: string;
|
|
36
|
+
AgentName: string;
|
|
37
|
+
Status: HandoffOfferStatus;
|
|
38
|
+
CreatedAtMs: number;
|
|
39
|
+
ExpiresAtMs: number;
|
|
40
|
+
/** When the offer left `Pending`, for retention. */
|
|
41
|
+
ResolvedAtMs?: number;
|
|
42
|
+
InteractionID?: string;
|
|
43
|
+
OfferedByAgentID?: string | null;
|
|
44
|
+
}
|
|
45
|
+
/** What it takes to create an offer. */
|
|
46
|
+
export interface CreateOfferInput {
|
|
47
|
+
RoomName: string;
|
|
48
|
+
TargetUserID: string;
|
|
49
|
+
Mode: HandoffMode;
|
|
50
|
+
Summary: string;
|
|
51
|
+
CallerLabel: string;
|
|
52
|
+
AgentName: string;
|
|
53
|
+
InteractionID?: string;
|
|
54
|
+
OfferedByAgentID?: string | null;
|
|
55
|
+
ContextUser?: UserInfo;
|
|
56
|
+
Provider?: IMetadataProvider;
|
|
57
|
+
}
|
|
58
|
+
/** The result of trying to resolve an offer on a person's behalf. */
|
|
59
|
+
export type ResolveOfferResult = {
|
|
60
|
+
Ok: true;
|
|
61
|
+
Offer: HandoffOfferRecord;
|
|
62
|
+
} | {
|
|
63
|
+
Ok: false;
|
|
64
|
+
Reason: string;
|
|
65
|
+
};
|
|
66
|
+
/** The one answer for "no such offer", "not yours" and "too late", so an offer's existence is not leaked. */
|
|
67
|
+
export declare const OFFER_UNAVAILABLE = "This conversation offer is no longer available.";
|
|
68
|
+
/** Converts a stored offer to what a console may see. */
|
|
69
|
+
export declare function ToOfferView(offer: HandoffOfferRecord): HandoffOfferView;
|
|
70
|
+
export interface HandoffOfferRegistryDeps {
|
|
71
|
+
Provider?: IMetadataProvider;
|
|
72
|
+
ContextUser?: UserInfo;
|
|
73
|
+
Publisher?: (event: {
|
|
74
|
+
UserID: string;
|
|
75
|
+
Kind: 'offered' | 'updated';
|
|
76
|
+
Offer: HandoffOfferView;
|
|
77
|
+
}) => void;
|
|
78
|
+
}
|
|
79
|
+
/** Holds handoff offers in `MJ: Interaction Offers` with in-memory caching and compare-and-set concurrency guards. */
|
|
80
|
+
export declare class HandoffOfferRegistry extends BaseSingleton<HandoffOfferRegistry> {
|
|
81
|
+
private deps;
|
|
82
|
+
private readonly offers;
|
|
83
|
+
private now;
|
|
84
|
+
private sweepTimer?;
|
|
85
|
+
protected constructor();
|
|
86
|
+
/** The process-wide registry. */
|
|
87
|
+
static get Instance(): HandoffOfferRegistry;
|
|
88
|
+
/** Configures collaborator dependencies. */
|
|
89
|
+
Configure(deps: HandoffOfferRegistryDeps): void;
|
|
90
|
+
/** Stops the periodic background sweep timer (shutdown, tests). */
|
|
91
|
+
StopPeriodicSweep(): void;
|
|
92
|
+
/** Replaces the clock (tests). */
|
|
93
|
+
SetClock(now: () => number): void;
|
|
94
|
+
/** Forgets every cached offer (tests, shutdown). */
|
|
95
|
+
Clear(): void;
|
|
96
|
+
/**
|
|
97
|
+
* Creates a pending offer. Refuses when the person already has {@link MAX_PENDING_OFFERS_PER_USER} pending.
|
|
98
|
+
*
|
|
99
|
+
* @returns The new offer, or `undefined` when the person's pending-offer cap is reached.
|
|
100
|
+
*/
|
|
101
|
+
Create(input: CreateOfferInput, timeoutMs?: number): Promise<HandoffOfferRecord | undefined>;
|
|
102
|
+
/** The offer with this id, or `undefined`. */
|
|
103
|
+
Get(offerID: string, contextUser?: UserInfo, provider?: IMetadataProvider): Promise<HandoffOfferRecord | undefined>;
|
|
104
|
+
/** The person's offers that can still be accepted, soonest to expire first. */
|
|
105
|
+
PendingForUser(userID: string, contextUser?: UserInfo, provider?: IMetadataProvider): Promise<HandoffOfferRecord[]>;
|
|
106
|
+
/** The person's offers to show: pending ones, plus ones resolved recently enough to still be retained. */
|
|
107
|
+
ListForUser(userID: string, contextUser?: UserInfo, provider?: IMetadataProvider): Promise<HandoffOfferRecord[]>;
|
|
108
|
+
/** The offers made from one room, any status. */
|
|
109
|
+
ForRoom(roomName: string, contextUser?: UserInfo, provider?: IMetadataProvider): Promise<HandoffOfferRecord[]>;
|
|
110
|
+
/**
|
|
111
|
+
* Moves a pending offer to a terminal status on behalf of the person it was made to. The caller must be the
|
|
112
|
+
* offer's target and the offer must still be pending and unexpired; anything else answers the same way.
|
|
113
|
+
*
|
|
114
|
+
* Compares and sets atomically in a transaction on `Status = 'Pending'` so concurrent requests across
|
|
115
|
+
* multiple instances cannot both succeed.
|
|
116
|
+
*/
|
|
117
|
+
ResolveForUser(offerID: string, userID: string, status: 'Accepted' | 'Declined', contextUser?: UserInfo, provider?: IMetadataProvider): Promise<ResolveOfferResult>;
|
|
118
|
+
/**
|
|
119
|
+
* Moves a pending offer to `Expired` or `Cancelled` (a system decision, not the person's).
|
|
120
|
+
*
|
|
121
|
+
* @returns The offer when it was pending and has now changed; `undefined` when it was already resolved or unknown.
|
|
122
|
+
*/
|
|
123
|
+
Close(offerID: string, status: 'Expired' | 'Cancelled', contextUser?: UserInfo, provider?: IMetadataProvider): Promise<HandoffOfferRecord | undefined>;
|
|
124
|
+
/**
|
|
125
|
+
* Sweeps expired pending offers and purges retained resolved offers.
|
|
126
|
+
*
|
|
127
|
+
* @returns How many pending offers were expired by this sweep.
|
|
128
|
+
*/
|
|
129
|
+
Sweep(contextUser?: UserInfo, provider?: IMetadataProvider): Promise<number>;
|
|
130
|
+
private sweep;
|
|
131
|
+
private finish;
|
|
132
|
+
private entityToRecord;
|
|
133
|
+
private notifyPublisher;
|
|
134
|
+
private getProvider;
|
|
135
|
+
private getRunView;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=handoff-offer-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handoff-offer-registry.d.ts","sourceRoot":"","sources":["../../src/room-handoff/handoff-offer-registry.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Durable registry of handoff offers backed by `MJ: Interaction Offers`.
|
|
3
|
+
*
|
|
4
|
+
* An offer is a short-lived question put to one person: "a caller is waiting, will you take the conversation?".
|
|
5
|
+
* It lives in `MJ: Interaction Offers` from the moment the AI asks until it is accepted, declined, expired or cancelled,
|
|
6
|
+
* and is kept so a console that was open when it resolved can still show what happened.
|
|
7
|
+
*
|
|
8
|
+
* **Durable and cluster-safe.** Offers are stored in the database (`MJ: Interaction Offers`). Accept and decline
|
|
9
|
+
* execute a compare-and-set on `Status = 'Pending'` inside `RunInEntityTransaction` so multiple server instances
|
|
10
|
+
* cannot both accept or race. Expired offers are updated on read and through a periodic sweep. Live changes are
|
|
11
|
+
* published locally and across instances via cluster pub/sub.
|
|
12
|
+
*
|
|
13
|
+
* An accept or decline is authorised by comparing the offer's target user with the caller, and a mismatch answers
|
|
14
|
+
* exactly as a missing offer does: someone who does not own an offer cannot learn that it exists.
|
|
15
|
+
*
|
|
16
|
+
* @module @memberjunction/livekit-room-server
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { LogError, Metadata, RunInEntityTransaction, RunView } from '@memberjunction/core';
|
|
20
|
+
import { BaseSingleton, EscapeSQLString, UUIDsEqual } from '@memberjunction/global';
|
|
21
|
+
/** How long a person has to accept an offer before it expires. */
|
|
22
|
+
export const HANDOFF_OFFER_TIMEOUT_MS = 45_000;
|
|
23
|
+
/** How long a resolved offer stays listed (so a console that was open still sees what happened). */
|
|
24
|
+
export const HANDOFF_OFFER_RETENTION_MS = 5 * 60_000;
|
|
25
|
+
/** The most pending offers one person can have at once; a flood of offers is refused rather than queued. */
|
|
26
|
+
export const MAX_PENDING_OFFERS_PER_USER = 10;
|
|
27
|
+
/** The one answer for "no such offer", "not yours" and "too late", so an offer's existence is not leaked. */
|
|
28
|
+
export const OFFER_UNAVAILABLE = 'This conversation offer is no longer available.';
|
|
29
|
+
/** Converts a stored offer to what a console may see. */
|
|
30
|
+
export function ToOfferView(offer) {
|
|
31
|
+
return {
|
|
32
|
+
OfferID: offer.OfferID,
|
|
33
|
+
RoomName: offer.RoomName,
|
|
34
|
+
Mode: offer.Mode,
|
|
35
|
+
Summary: offer.Summary,
|
|
36
|
+
CallerLabel: offer.CallerLabel,
|
|
37
|
+
AgentName: offer.AgentName,
|
|
38
|
+
Status: offer.Status,
|
|
39
|
+
CreatedAt: new Date(offer.CreatedAtMs).toISOString(),
|
|
40
|
+
ExpiresAt: new Date(offer.ExpiresAtMs).toISOString(),
|
|
41
|
+
InteractionID: offer.InteractionID ?? null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function asTransactionCapable(provider) {
|
|
45
|
+
if (!provider)
|
|
46
|
+
return undefined;
|
|
47
|
+
const candidate = provider;
|
|
48
|
+
return candidate.SupportsEntityTransactions === true && typeof candidate.BeginEntityTransaction === 'function'
|
|
49
|
+
? candidate
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Holds handoff offers in `MJ: Interaction Offers` with in-memory caching and compare-and-set concurrency guards. */
|
|
53
|
+
export class HandoffOfferRegistry extends BaseSingleton {
|
|
54
|
+
constructor() {
|
|
55
|
+
super();
|
|
56
|
+
this.deps = {};
|
|
57
|
+
this.offers = new Map();
|
|
58
|
+
this.now = Date.now;
|
|
59
|
+
this.sweepTimer = setInterval(() => {
|
|
60
|
+
void this.Sweep().catch((e) => {
|
|
61
|
+
LogError(`[HandoffOfferRegistry] periodic sweep failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
62
|
+
});
|
|
63
|
+
}, 15_000);
|
|
64
|
+
this.sweepTimer.unref?.();
|
|
65
|
+
}
|
|
66
|
+
/** The process-wide registry. */
|
|
67
|
+
static get Instance() {
|
|
68
|
+
return super.getInstance();
|
|
69
|
+
}
|
|
70
|
+
/** Configures collaborator dependencies. */
|
|
71
|
+
Configure(deps) {
|
|
72
|
+
this.deps = { ...this.deps, ...deps };
|
|
73
|
+
}
|
|
74
|
+
/** Stops the periodic background sweep timer (shutdown, tests). */
|
|
75
|
+
StopPeriodicSweep() {
|
|
76
|
+
if (this.sweepTimer) {
|
|
77
|
+
clearInterval(this.sweepTimer);
|
|
78
|
+
this.sweepTimer = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Replaces the clock (tests). */
|
|
82
|
+
SetClock(now) {
|
|
83
|
+
this.now = now;
|
|
84
|
+
}
|
|
85
|
+
/** Forgets every cached offer (tests, shutdown). */
|
|
86
|
+
Clear() {
|
|
87
|
+
this.offers.clear();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Creates a pending offer. Refuses when the person already has {@link MAX_PENDING_OFFERS_PER_USER} pending.
|
|
91
|
+
*
|
|
92
|
+
* @returns The new offer, or `undefined` when the person's pending-offer cap is reached.
|
|
93
|
+
*/
|
|
94
|
+
async Create(input, timeoutMs = HANDOFF_OFFER_TIMEOUT_MS) {
|
|
95
|
+
await this.sweep(input.ContextUser, input.Provider);
|
|
96
|
+
const pending = await this.PendingForUser(input.TargetUserID, input.ContextUser, input.Provider);
|
|
97
|
+
if (pending.length >= MAX_PENDING_OFFERS_PER_USER) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const createdAt = this.now();
|
|
101
|
+
const expiresAt = createdAt + timeoutMs;
|
|
102
|
+
const p = this.getProvider(input.Provider);
|
|
103
|
+
if (p && typeof p.GetEntityObject === 'function') {
|
|
104
|
+
const user = input.ContextUser ?? this.deps.ContextUser;
|
|
105
|
+
try {
|
|
106
|
+
let interactionID = input.InteractionID;
|
|
107
|
+
if (!interactionID) {
|
|
108
|
+
const rv = this.getRunView(p);
|
|
109
|
+
const existing = await rv.RunView({
|
|
110
|
+
EntityName: 'MJ: Interactions',
|
|
111
|
+
ExtraFilter: `RoomName = '${EscapeSQLString(input.RoomName.trim())}' AND Status = 'Active'`,
|
|
112
|
+
ResultType: 'entity_object',
|
|
113
|
+
}, user);
|
|
114
|
+
if (existing.Success && existing.Results && existing.Results.length > 0) {
|
|
115
|
+
interactionID = existing.Results[0].ID;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const interaction = await p.GetEntityObject('MJ: Interactions', user);
|
|
119
|
+
interaction.Channel = 'Web';
|
|
120
|
+
interaction.Direction = 'Inbound';
|
|
121
|
+
interaction.RoomName = input.RoomName;
|
|
122
|
+
interaction.Status = 'Active';
|
|
123
|
+
interaction.StartedAt = new Date(createdAt);
|
|
124
|
+
const savedInteraction = await interaction.Save();
|
|
125
|
+
if (savedInteraction) {
|
|
126
|
+
interactionID = interaction.ID;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (interactionID) {
|
|
131
|
+
const entity = await p.GetEntityObject('MJ: Interaction Offers', user);
|
|
132
|
+
entity.InteractionID = interactionID;
|
|
133
|
+
entity.TargetUserID = input.TargetUserID;
|
|
134
|
+
entity.OfferedByAgentID = input.OfferedByAgentID ?? null;
|
|
135
|
+
entity.Mode = input.Mode.toLowerCase() === 'warm' ? 'Warm' : 'Blind';
|
|
136
|
+
entity.Status = 'Pending';
|
|
137
|
+
entity.RoomName = input.RoomName;
|
|
138
|
+
entity.CallerLabel = input.CallerLabel;
|
|
139
|
+
entity.Summary = input.Summary;
|
|
140
|
+
entity.OfferedAt = new Date(createdAt);
|
|
141
|
+
entity.ExpiresAt = new Date(expiresAt);
|
|
142
|
+
const saved = await entity.Save();
|
|
143
|
+
if (saved) {
|
|
144
|
+
const record = this.entityToRecord(entity, input.AgentName);
|
|
145
|
+
this.offers.set(record.OfferID, record);
|
|
146
|
+
return record;
|
|
147
|
+
}
|
|
148
|
+
LogError(`[HandoffOfferRegistry] Failed to save offer entity: ${entity.LatestResult?.CompleteMessage ?? 'unknown'}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
LogError(`[HandoffOfferRegistry] Failed to create database offer: ${err instanceof Error ? err.message : String(err)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// In-memory fallback
|
|
156
|
+
const offer = {
|
|
157
|
+
OfferID: randomUUID(),
|
|
158
|
+
RoomName: input.RoomName,
|
|
159
|
+
TargetUserID: input.TargetUserID,
|
|
160
|
+
Mode: input.Mode,
|
|
161
|
+
Summary: input.Summary,
|
|
162
|
+
CallerLabel: input.CallerLabel,
|
|
163
|
+
AgentName: input.AgentName,
|
|
164
|
+
Status: 'Pending',
|
|
165
|
+
CreatedAtMs: createdAt,
|
|
166
|
+
ExpiresAtMs: expiresAt,
|
|
167
|
+
InteractionID: input.InteractionID,
|
|
168
|
+
OfferedByAgentID: input.OfferedByAgentID ?? null,
|
|
169
|
+
};
|
|
170
|
+
this.offers.set(offer.OfferID, offer);
|
|
171
|
+
return offer;
|
|
172
|
+
}
|
|
173
|
+
/** The offer with this id, or `undefined`. */
|
|
174
|
+
async Get(offerID, contextUser, provider) {
|
|
175
|
+
const p = this.getProvider(provider);
|
|
176
|
+
const now = this.now();
|
|
177
|
+
if (p && typeof p.GetEntityObject === 'function') {
|
|
178
|
+
try {
|
|
179
|
+
const user = contextUser ?? this.deps.ContextUser;
|
|
180
|
+
const entity = await p.GetEntityObject('MJ: Interaction Offers', user);
|
|
181
|
+
const loaded = await entity.Load(offerID);
|
|
182
|
+
if (loaded) {
|
|
183
|
+
const expiresAtMs = entity.ExpiresAt ? new Date(entity.ExpiresAt).getTime() : 0;
|
|
184
|
+
if (entity.Status === 'Pending' && expiresAtMs <= now) {
|
|
185
|
+
entity.Status = 'Expired';
|
|
186
|
+
entity.RespondedAt = new Date(now);
|
|
187
|
+
await entity.Save();
|
|
188
|
+
const record = this.entityToRecord(entity);
|
|
189
|
+
this.offers.set(record.OfferID, record);
|
|
190
|
+
this.notifyPublisher(record);
|
|
191
|
+
return record;
|
|
192
|
+
}
|
|
193
|
+
const record = this.entityToRecord(entity);
|
|
194
|
+
this.offers.set(record.OfferID, record);
|
|
195
|
+
return record;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
LogError(`[HandoffOfferRegistry] Get(${offerID}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const cached = this.offers.get(offerID);
|
|
203
|
+
if (cached && cached.Status === 'Pending' && cached.ExpiresAtMs <= now) {
|
|
204
|
+
this.finish(cached, 'Expired');
|
|
205
|
+
this.notifyPublisher(cached);
|
|
206
|
+
}
|
|
207
|
+
return cached;
|
|
208
|
+
}
|
|
209
|
+
/** The person's offers that can still be accepted, soonest to expire first. */
|
|
210
|
+
async PendingForUser(userID, contextUser, provider) {
|
|
211
|
+
const p = this.getProvider(provider);
|
|
212
|
+
const now = this.now();
|
|
213
|
+
if (p && typeof p.GetEntityObject === 'function') {
|
|
214
|
+
try {
|
|
215
|
+
const rv = this.getRunView(p);
|
|
216
|
+
const user = contextUser ?? this.deps.ContextUser;
|
|
217
|
+
const result = await rv.RunView({
|
|
218
|
+
EntityName: 'MJ: Interaction Offers',
|
|
219
|
+
ExtraFilter: `TargetUserID = '${EscapeSQLString(userID)}' AND Status = 'Pending'`,
|
|
220
|
+
OrderBy: 'ExpiresAt ASC',
|
|
221
|
+
ResultType: 'entity_object',
|
|
222
|
+
}, user);
|
|
223
|
+
if (result.Success && result.Results) {
|
|
224
|
+
const records = [];
|
|
225
|
+
for (const entity of result.Results) {
|
|
226
|
+
const expiresAtMs = entity.ExpiresAt ? new Date(entity.ExpiresAt).getTime() : 0;
|
|
227
|
+
if (expiresAtMs <= now) {
|
|
228
|
+
entity.Status = 'Expired';
|
|
229
|
+
entity.RespondedAt = new Date(now);
|
|
230
|
+
await entity.Save();
|
|
231
|
+
const rec = this.entityToRecord(entity);
|
|
232
|
+
this.offers.set(rec.OfferID, rec);
|
|
233
|
+
this.notifyPublisher(rec);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const rec = this.entityToRecord(entity);
|
|
237
|
+
this.offers.set(rec.OfferID, rec);
|
|
238
|
+
records.push(rec);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return records.sort((a, b) => a.ExpiresAtMs - b.ExpiresAtMs);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
LogError(`[HandoffOfferRegistry] PendingForUser query failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// In-memory fallback
|
|
249
|
+
return [...this.offers.values()]
|
|
250
|
+
.filter((o) => o.Status === 'Pending' && o.ExpiresAtMs > now && UUIDsEqual(o.TargetUserID, userID))
|
|
251
|
+
.sort((a, b) => a.ExpiresAtMs - b.ExpiresAtMs);
|
|
252
|
+
}
|
|
253
|
+
/** The person's offers to show: pending ones, plus ones resolved recently enough to still be retained. */
|
|
254
|
+
async ListForUser(userID, contextUser, provider) {
|
|
255
|
+
await this.sweep(contextUser, provider);
|
|
256
|
+
const p = this.getProvider(provider);
|
|
257
|
+
const now = this.now();
|
|
258
|
+
if (p && typeof p.GetEntityObject === 'function') {
|
|
259
|
+
try {
|
|
260
|
+
const cutoff = new Date(now - HANDOFF_OFFER_RETENTION_MS).toISOString();
|
|
261
|
+
const rv = this.getRunView(p);
|
|
262
|
+
const user = contextUser ?? this.deps.ContextUser;
|
|
263
|
+
const result = await rv.RunView({
|
|
264
|
+
EntityName: 'MJ: Interaction Offers',
|
|
265
|
+
ExtraFilter: `TargetUserID = '${EscapeSQLString(userID)}' AND (Status = 'Pending' OR RespondedAt >= '${cutoff}')`,
|
|
266
|
+
OrderBy: 'OfferedAt DESC',
|
|
267
|
+
ResultType: 'entity_object',
|
|
268
|
+
}, user);
|
|
269
|
+
if (result.Success && result.Results) {
|
|
270
|
+
const records = result.Results.map((e) => {
|
|
271
|
+
const rec = this.entityToRecord(e);
|
|
272
|
+
this.offers.set(rec.OfferID, rec);
|
|
273
|
+
return rec;
|
|
274
|
+
});
|
|
275
|
+
return records.sort((a, b) => b.CreatedAtMs - a.CreatedAtMs);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
LogError(`[HandoffOfferRegistry] ListForUser query failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// In-memory fallback
|
|
283
|
+
return [...this.offers.values()]
|
|
284
|
+
.filter((o) => UUIDsEqual(o.TargetUserID, userID))
|
|
285
|
+
.sort((a, b) => b.CreatedAtMs - a.CreatedAtMs);
|
|
286
|
+
}
|
|
287
|
+
/** The offers made from one room, any status. */
|
|
288
|
+
async ForRoom(roomName, contextUser, provider) {
|
|
289
|
+
const key = roomName.trim().toLowerCase();
|
|
290
|
+
const p = this.getProvider(provider);
|
|
291
|
+
if (p && typeof p.GetEntityObject === 'function') {
|
|
292
|
+
try {
|
|
293
|
+
const rv = this.getRunView(p);
|
|
294
|
+
const user = contextUser ?? this.deps.ContextUser;
|
|
295
|
+
const result = await rv.RunView({
|
|
296
|
+
EntityName: 'MJ: Interaction Offers',
|
|
297
|
+
ExtraFilter: `RoomName = '${EscapeSQLString(roomName.trim())}'`,
|
|
298
|
+
OrderBy: 'OfferedAt DESC',
|
|
299
|
+
ResultType: 'entity_object',
|
|
300
|
+
}, user);
|
|
301
|
+
if (result.Success && result.Results) {
|
|
302
|
+
return result.Results.map((e) => this.entityToRecord(e));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
LogError(`[HandoffOfferRegistry] ForRoom query failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// In-memory fallback
|
|
310
|
+
return [...this.offers.values()].filter((o) => o.RoomName.trim().toLowerCase() === key);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Moves a pending offer to a terminal status on behalf of the person it was made to. The caller must be the
|
|
314
|
+
* offer's target and the offer must still be pending and unexpired; anything else answers the same way.
|
|
315
|
+
*
|
|
316
|
+
* Compares and sets atomically in a transaction on `Status = 'Pending'` so concurrent requests across
|
|
317
|
+
* multiple instances cannot both succeed.
|
|
318
|
+
*/
|
|
319
|
+
async ResolveForUser(offerID, userID, status, contextUser, provider) {
|
|
320
|
+
const p = this.getProvider(provider);
|
|
321
|
+
if (p && typeof p.GetEntityObject === 'function') {
|
|
322
|
+
const user = contextUser ?? this.deps.ContextUser;
|
|
323
|
+
try {
|
|
324
|
+
return await RunInEntityTransaction(asTransactionCapable(p), async () => {
|
|
325
|
+
const entity = await p.GetEntityObject('MJ: Interaction Offers', user);
|
|
326
|
+
const loaded = await entity.Load(offerID);
|
|
327
|
+
if (!loaded) {
|
|
328
|
+
return { Ok: false, Reason: OFFER_UNAVAILABLE };
|
|
329
|
+
}
|
|
330
|
+
if (!UUIDsEqual(entity.TargetUserID, userID)) {
|
|
331
|
+
return { Ok: false, Reason: OFFER_UNAVAILABLE };
|
|
332
|
+
}
|
|
333
|
+
const now = this.now();
|
|
334
|
+
const expiresAtMs = entity.ExpiresAt ? new Date(entity.ExpiresAt).getTime() : 0;
|
|
335
|
+
if (entity.Status !== 'Pending' || expiresAtMs <= now) {
|
|
336
|
+
if (entity.Status === 'Pending' && expiresAtMs <= now) {
|
|
337
|
+
entity.Status = 'Expired';
|
|
338
|
+
entity.RespondedAt = new Date(now);
|
|
339
|
+
await entity.Save();
|
|
340
|
+
const rec = this.entityToRecord(entity);
|
|
341
|
+
this.offers.set(rec.OfferID, rec);
|
|
342
|
+
this.notifyPublisher(rec);
|
|
343
|
+
}
|
|
344
|
+
return { Ok: false, Reason: OFFER_UNAVAILABLE };
|
|
345
|
+
}
|
|
346
|
+
entity.Status = status;
|
|
347
|
+
entity.RespondedAt = new Date(now);
|
|
348
|
+
const saved = await entity.Save();
|
|
349
|
+
if (!saved) {
|
|
350
|
+
return { Ok: false, Reason: OFFER_UNAVAILABLE };
|
|
351
|
+
}
|
|
352
|
+
const record = this.entityToRecord(entity);
|
|
353
|
+
this.offers.set(record.OfferID, record);
|
|
354
|
+
this.notifyPublisher(record);
|
|
355
|
+
return { Ok: true, Offer: record };
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
LogError(`[HandoffOfferRegistry] ResolveForUser transaction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
360
|
+
return { Ok: false, Reason: OFFER_UNAVAILABLE };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// In-memory fallback
|
|
364
|
+
const offer = this.offers.get(offerID);
|
|
365
|
+
if (!offer || !UUIDsEqual(offer.TargetUserID, userID)) {
|
|
366
|
+
return { Ok: false, Reason: OFFER_UNAVAILABLE };
|
|
367
|
+
}
|
|
368
|
+
if (offer.Status !== 'Pending' || offer.ExpiresAtMs <= this.now()) {
|
|
369
|
+
return { Ok: false, Reason: OFFER_UNAVAILABLE };
|
|
370
|
+
}
|
|
371
|
+
this.finish(offer, status);
|
|
372
|
+
this.notifyPublisher(offer);
|
|
373
|
+
return { Ok: true, Offer: offer };
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Moves a pending offer to `Expired` or `Cancelled` (a system decision, not the person's).
|
|
377
|
+
*
|
|
378
|
+
* @returns The offer when it was pending and has now changed; `undefined` when it was already resolved or unknown.
|
|
379
|
+
*/
|
|
380
|
+
async Close(offerID, status, contextUser, provider) {
|
|
381
|
+
const p = this.getProvider(provider);
|
|
382
|
+
if (p && typeof p.GetEntityObject === 'function') {
|
|
383
|
+
const user = contextUser ?? this.deps.ContextUser;
|
|
384
|
+
try {
|
|
385
|
+
return await RunInEntityTransaction(asTransactionCapable(p), async () => {
|
|
386
|
+
const entity = await p.GetEntityObject('MJ: Interaction Offers', user);
|
|
387
|
+
const loaded = await entity.Load(offerID);
|
|
388
|
+
if (!loaded || entity.Status !== 'Pending') {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
entity.Status = status;
|
|
392
|
+
entity.RespondedAt = new Date(this.now());
|
|
393
|
+
const saved = await entity.Save();
|
|
394
|
+
if (!saved) {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
const record = this.entityToRecord(entity);
|
|
398
|
+
this.offers.set(record.OfferID, record);
|
|
399
|
+
this.notifyPublisher(record);
|
|
400
|
+
return record;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
LogError(`[HandoffOfferRegistry] Close transaction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// In-memory fallback
|
|
409
|
+
const offer = this.offers.get(offerID);
|
|
410
|
+
if (!offer || offer.Status !== 'Pending') {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
this.finish(offer, status);
|
|
414
|
+
this.notifyPublisher(offer);
|
|
415
|
+
return offer;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Sweeps expired pending offers and purges retained resolved offers.
|
|
419
|
+
*
|
|
420
|
+
* @returns How many pending offers were expired by this sweep.
|
|
421
|
+
*/
|
|
422
|
+
async Sweep(contextUser, provider) {
|
|
423
|
+
return this.sweep(contextUser, provider);
|
|
424
|
+
}
|
|
425
|
+
async sweep(contextUser, provider) {
|
|
426
|
+
const now = this.now();
|
|
427
|
+
let swept = 0;
|
|
428
|
+
const p = this.getProvider(provider);
|
|
429
|
+
if (p && typeof p.GetEntityObject === 'function') {
|
|
430
|
+
try {
|
|
431
|
+
const rv = this.getRunView(p);
|
|
432
|
+
const user = contextUser ?? this.deps.ContextUser;
|
|
433
|
+
const result = await rv.RunView({
|
|
434
|
+
EntityName: 'MJ: Interaction Offers',
|
|
435
|
+
ExtraFilter: `Status = 'Pending' AND ExpiresAt <= '${new Date(now).toISOString()}'`,
|
|
436
|
+
ResultType: 'entity_object',
|
|
437
|
+
}, user);
|
|
438
|
+
if (result.Success && result.Results) {
|
|
439
|
+
for (const entity of result.Results) {
|
|
440
|
+
entity.Status = 'Expired';
|
|
441
|
+
entity.RespondedAt = new Date(now);
|
|
442
|
+
const saved = await entity.Save();
|
|
443
|
+
if (saved) {
|
|
444
|
+
const record = this.entityToRecord(entity);
|
|
445
|
+
this.offers.set(record.OfferID, record);
|
|
446
|
+
this.notifyPublisher(record);
|
|
447
|
+
swept++;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
LogError(`[HandoffOfferRegistry] sweep failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const cutoff = now - HANDOFF_OFFER_RETENTION_MS;
|
|
457
|
+
for (const [id, offer] of this.offers) {
|
|
458
|
+
if (offer.Status === 'Pending' && offer.ExpiresAtMs <= now) {
|
|
459
|
+
this.finish(offer, 'Expired');
|
|
460
|
+
this.notifyPublisher(offer);
|
|
461
|
+
swept++;
|
|
462
|
+
}
|
|
463
|
+
if (offer.ResolvedAtMs !== undefined && offer.ResolvedAtMs < cutoff) {
|
|
464
|
+
this.offers.delete(id);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return swept;
|
|
468
|
+
}
|
|
469
|
+
finish(offer, status) {
|
|
470
|
+
offer.Status = status;
|
|
471
|
+
offer.ResolvedAtMs = this.now();
|
|
472
|
+
}
|
|
473
|
+
entityToRecord(entity, agentNameFallback) {
|
|
474
|
+
const createdAtMs = entity.OfferedAt
|
|
475
|
+
? new Date(entity.OfferedAt).getTime()
|
|
476
|
+
: entity.__mj_CreatedAt
|
|
477
|
+
? new Date(entity.__mj_CreatedAt).getTime()
|
|
478
|
+
: this.now();
|
|
479
|
+
const expiresAtMs = entity.ExpiresAt ? new Date(entity.ExpiresAt).getTime() : createdAtMs + HANDOFF_OFFER_TIMEOUT_MS;
|
|
480
|
+
const resolvedAtMs = entity.RespondedAt ? new Date(entity.RespondedAt).getTime() : undefined;
|
|
481
|
+
const mode = (entity.Mode?.toLowerCase() === 'warm' ? 'warm' : 'blind');
|
|
482
|
+
const agentName = entity.OfferedByAgent || agentNameFallback || 'AI Agent';
|
|
483
|
+
return {
|
|
484
|
+
OfferID: entity.ID,
|
|
485
|
+
InteractionID: entity.InteractionID,
|
|
486
|
+
OfferedByAgentID: entity.OfferedByAgentID,
|
|
487
|
+
RoomName: entity.RoomName,
|
|
488
|
+
TargetUserID: entity.TargetUserID,
|
|
489
|
+
Mode: mode,
|
|
490
|
+
Summary: entity.Summary ?? '',
|
|
491
|
+
CallerLabel: entity.CallerLabel ?? '',
|
|
492
|
+
AgentName: agentName,
|
|
493
|
+
Status: entity.Status,
|
|
494
|
+
CreatedAtMs: createdAtMs,
|
|
495
|
+
ExpiresAtMs: expiresAtMs,
|
|
496
|
+
ResolvedAtMs: resolvedAtMs,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
notifyPublisher(record) {
|
|
500
|
+
try {
|
|
501
|
+
this.deps.Publisher?.({
|
|
502
|
+
UserID: record.TargetUserID,
|
|
503
|
+
Kind: 'updated',
|
|
504
|
+
Offer: ToOfferView(record),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
LogError(`[HandoffOfferRegistry] publish failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
getProvider(callProvider) {
|
|
512
|
+
return callProvider ?? this.deps.Provider ?? Metadata.Provider;
|
|
513
|
+
}
|
|
514
|
+
getRunView(provider) {
|
|
515
|
+
if (typeof provider.RunView === 'function') {
|
|
516
|
+
return RunView.FromMetadataProvider(provider);
|
|
517
|
+
}
|
|
518
|
+
return new RunView();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
//# sourceMappingURL=handoff-offer-registry.js.map
|