crewly 1.20.149 → 1.20.151
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/dist/backend/backend/src/constants.d.ts +100 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +101 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts +1 -0
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +22 -0
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.routes.d.ts +1 -0
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.routes.js +2 -0
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/messaging/messenger.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/messaging/messenger.routes.js +42 -1
- package/dist/backend/backend/src/controllers/messaging/messenger.routes.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts +8 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +109 -1
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.relay-adapter.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.relay-adapter.service.js +3 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.relay-adapter.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +94 -2
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +183 -2
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/owner-inbound.utils.d.ts +70 -0
- package/dist/backend/backend/src/services/chat-v2/owner-inbound.utils.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/owner-inbound.utils.js +78 -0
- package/dist/backend/backend/src/services/chat-v2/owner-inbound.utils.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.js +9 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/chat-db.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/cloud-outbox.store.d.ts +136 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/cloud-outbox.store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/cloud-outbox.store.js +207 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/cloud-outbox.store.js.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +62 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js +111 -4
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/unified-log.d.ts +206 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/unified-log.d.ts.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/unified-log.js +442 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/unified-log.js.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/types.d.ts +29 -0
- package/dist/backend/backend/src/services/chat-v2/types.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/types.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/agent-roster.utils.d.ts +31 -0
- package/dist/backend/backend/src/services/cloud/agent-roster.utils.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/agent-roster.utils.js +53 -0
- package/dist/backend/backend/src/services/cloud/agent-roster.utils.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-sync.types.d.ts +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-sync.types.js +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-sync.types.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.d.ts +125 -0
- package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.js +272 -0
- package/dist/backend/backend/src/services/cloud/cloud-talk-inbound.service.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/conversation-cloud-sync.service.d.ts +229 -0
- package/dist/backend/backend/src/services/cloud/conversation-cloud-sync.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/conversation-cloud-sync.service.js +655 -0
- package/dist/backend/backend/src/services/cloud/conversation-cloud-sync.service.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/conversation-ingest.contract.d.ts +215 -0
- package/dist/backend/backend/src/services/cloud/conversation-ingest.contract.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/conversation-ingest.contract.js +193 -0
- package/dist/backend/backend/src/services/cloud/conversation-ingest.contract.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +4 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/google-chat-initializer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/google-chat-initializer.js +11 -1
- package/dist/backend/backend/src/services/messaging/google-chat-initializer.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +13 -0
- package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-initializer.js +15 -0
- package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-instance-registry.service.d.ts +5 -0
- package/dist/backend/backend/src/services/slack/slack-instance-registry.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-instance-registry.service.js +5 -0
- package/dist/backend/backend/src/services/slack/slack-instance-registry.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +14 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +34 -2
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +1 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/telegram/telegram-orchestrator-bridge.d.ts +10 -0
- package/dist/backend/backend/src/services/telegram/telegram-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/telegram/telegram-orchestrator-bridge.js +27 -2
- package/dist/backend/backend/src/services/telegram/telegram-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/services/whatsapp/whatsapp-orchestrator-bridge.d.ts +10 -0
- package/dist/backend/backend/src/services/whatsapp/whatsapp-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/whatsapp/whatsapp-orchestrator-bridge.js +27 -4
- package/dist/backend/backend/src/services/whatsapp/whatsapp-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/types/slack.types.d.ts +24 -0
- package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/slack.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +100 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +101 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +94 -2
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +183 -2
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.js +9 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/chat-db.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/cloud-outbox.store.d.ts +136 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/cloud-outbox.store.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/cloud-outbox.store.js +207 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/cloud-outbox.store.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +62 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js +111 -4
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/unified-log.d.ts +206 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/unified-log.d.ts.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/unified-log.js +442 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/unified-log.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/types.d.ts +29 -0
- package/dist/cli/backend/src/services/chat-v2/types.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/types.js.map +1 -1
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts +1 -1
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -1
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.js +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts +1 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +13 -0
- package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +14 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +34 -2
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +1 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/types/slack.types.d.ts +24 -0
- package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/slack.types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationCloudSyncService — uploads this machine's conversation log to
|
|
3
|
+
* Crewly Cloud (specs/unified-conversations-cloud-store.md §B).
|
|
4
|
+
*
|
|
5
|
+
* - **Live:** drains `cloud_outbox` (filled by triggers on `chat_messages`)
|
|
6
|
+
* in `seq` order, one request in flight, batches of ≤ 50 messages /
|
|
7
|
+
* 256 KB, gzip. Wakes on every new chat message (after a 2 s gather) and
|
|
8
|
+
* on a 10 s tick. Rows leave the outbox only once Cloud acknowledged them.
|
|
9
|
+
* - **Backfill:** after first sign-in (or an account switch, or a plan
|
|
10
|
+
* upgrade that widens the window) it re-sends the history inside the
|
|
11
|
+
* plan's retention window, oldest first, at most one request a second,
|
|
12
|
+
* after the live queue.
|
|
13
|
+
* - **Disconnected / signed out:** the outbox stays on disk; it is only
|
|
14
|
+
* trimmed (older than 90 days, more than 200k rows — a history gap).
|
|
15
|
+
* - **Failures:** exponential backoff 1 s → 5 min. A 404 (Cloud has no
|
|
16
|
+
* conversation store yet), `403 sync_disabled`, `503` key missing or a
|
|
17
|
+
* `400` batch Cloud refuses pause sync for an hour, logged once.
|
|
18
|
+
* - **O1:** on by default for a signed-in machine; the owner gets one Slack
|
|
19
|
+
* DM the first time history reaches Cloud. `CREWLY_CONVERSATION_SYNC=0`
|
|
20
|
+
* (or `CREWLY_CLOUD_CONVERSATIONS=off`) turns uploading off; the outbox
|
|
21
|
+
* still fills.
|
|
22
|
+
* - **O4:** text only — attachments go as name / size / mime.
|
|
23
|
+
* - **Roster (Cloud Talk, Phase 3):** every 5 minutes, and whenever the
|
|
24
|
+
* advertised capabilities change, an empty batch carries this machine's
|
|
25
|
+
* agent roster and capabilities (`talk_message`), so Cloud lists agents
|
|
26
|
+
* that have no messages yet and knows Talk can be sent here — also on
|
|
27
|
+
* machines that do not use Slack (and so never send the Slack heartbeat).
|
|
28
|
+
*
|
|
29
|
+
* The wire contract lives in `conversation-ingest.contract.ts`.
|
|
30
|
+
*
|
|
31
|
+
* @module services/cloud/conversation-cloud-sync.service
|
|
32
|
+
*/
|
|
33
|
+
import { gzipSync } from 'zlib';
|
|
34
|
+
import { randomUUID } from 'crypto';
|
|
35
|
+
import { CLOUD_TALK_CONSTANTS, CONVERSATION_SYNC_CONSTANTS, ORCHESTRATOR_SESSION_NAME } from '../../constants.js';
|
|
36
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
37
|
+
import { CONVERSATION_DIRECTIONS, CONVERSATION_INGEST_PATH, CONVERSATION_SOURCES, INGEST_ERROR_CODES, INGEST_LIMITS, INSTANCE_ID_PATTERN, SENDER_KINDS, isOneOf, parseIngestErrorCode, parseIngestResponse, } from './conversation-ingest.contract.js';
|
|
38
|
+
const C = CONVERSATION_SYNC_CONSTANTS;
|
|
39
|
+
const K = C.STATE_KEYS;
|
|
40
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
/**
|
|
42
|
+
* Whether the kill switch is set.
|
|
43
|
+
*
|
|
44
|
+
* @param env - Environment
|
|
45
|
+
* @returns True when uploading is switched off
|
|
46
|
+
*/
|
|
47
|
+
export function isConversationSyncDisabled(env) {
|
|
48
|
+
const off = (v) => typeof v === 'string' && ['0', 'off', 'false', 'no'].includes(v.trim().toLowerCase());
|
|
49
|
+
return off(env[C.ENV_SWITCH]) || off(env[C.ENV_SWITCH_ALT]);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The account a Cloud access token belongs to — the JWT `sub`, read without
|
|
53
|
+
* verifying. An opaque token yields a constant, so token refreshes are never
|
|
54
|
+
* mistaken for an account switch.
|
|
55
|
+
*
|
|
56
|
+
* @param token - Access token
|
|
57
|
+
* @returns The account id
|
|
58
|
+
*/
|
|
59
|
+
export function accountIdOfToken(token) {
|
|
60
|
+
const parts = token.split('.');
|
|
61
|
+
if (parts.length === 3) {
|
|
62
|
+
try {
|
|
63
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
64
|
+
if (typeof payload.sub === 'string' && payload.sub.length > 0)
|
|
65
|
+
return payload.sub;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// not a JWT
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return 'opaque-token';
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse a JSON column without throwing.
|
|
75
|
+
*
|
|
76
|
+
* @param raw - Column value
|
|
77
|
+
* @returns The value, or undefined
|
|
78
|
+
*/
|
|
79
|
+
function parseJson(raw) {
|
|
80
|
+
if (!raw)
|
|
81
|
+
return undefined;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(raw);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Map one stored message to the wire shape.
|
|
91
|
+
*
|
|
92
|
+
* @param row - Message row with its channel and attachment metadata
|
|
93
|
+
* @param localSeq - Outbox seq (live) or 0 (backfill)
|
|
94
|
+
* @returns The upsert, or null when the row must not leave the machine
|
|
95
|
+
*/
|
|
96
|
+
export function toIngestUpsert(row, localSeq) {
|
|
97
|
+
if (row.cloudSync === 0)
|
|
98
|
+
return null;
|
|
99
|
+
const mentionsRaw = parseJson(row.mentions);
|
|
100
|
+
const mentions = Array.isArray(mentionsRaw) ? mentionsRaw.filter((m) => typeof m === 'string' && m.length > 0) : [];
|
|
101
|
+
const metadata = parseJson(row.metadata);
|
|
102
|
+
const md = metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? metadata : {};
|
|
103
|
+
const extRaw = parseJson(row.extRef);
|
|
104
|
+
const ext = extRaw && typeof extRaw === 'object' && !Array.isArray(extRaw) && Object.keys(extRaw).length > 0 ? extRaw : undefined;
|
|
105
|
+
const source = isOneOf(CONVERSATION_SOURCES, row.source) ? row.source : 'crewly-chat';
|
|
106
|
+
const direction = isOneOf(CONVERSATION_DIRECTIONS, row.direction)
|
|
107
|
+
? row.direction
|
|
108
|
+
: row.senderType === 'agent'
|
|
109
|
+
? 'out'
|
|
110
|
+
: row.senderType === 'system'
|
|
111
|
+
? 'internal'
|
|
112
|
+
: 'in';
|
|
113
|
+
const senderKind = isOneOf(SENDER_KINDS, row.senderKind)
|
|
114
|
+
? row.senderKind
|
|
115
|
+
: row.senderType === 'user'
|
|
116
|
+
? 'owner'
|
|
117
|
+
: row.senderType;
|
|
118
|
+
const agentSession = row.agentSession || mentions[0] || row.leadMember || ORCHESTRATOR_SESSION_NAME;
|
|
119
|
+
// A Slack person is identified by their Slack id; the display name rides along.
|
|
120
|
+
const slackUserId = typeof ext?.slackUserId === 'string' ? ext.slackUserId : undefined;
|
|
121
|
+
const sender = row.senderType === 'user' && source === 'slack' && slackUserId && slackUserId !== row.senderId
|
|
122
|
+
? { id: slackUserId, name: row.senderId }
|
|
123
|
+
: { id: row.senderId || row.senderType };
|
|
124
|
+
const clientMessageId = source === 'cloud-talk' && typeof md.clientMessageId === 'string' ? md.clientMessageId : undefined;
|
|
125
|
+
return {
|
|
126
|
+
localId: row.id,
|
|
127
|
+
op: 'upsert',
|
|
128
|
+
channel: { localId: row.channelId, kind: row.channelType, name: row.channelName },
|
|
129
|
+
agentSession,
|
|
130
|
+
...(mentions.length > 0 ? { mentions } : {}),
|
|
131
|
+
source,
|
|
132
|
+
direction,
|
|
133
|
+
senderKind,
|
|
134
|
+
sender,
|
|
135
|
+
...(ext ? { ext } : {}),
|
|
136
|
+
...(row.threadId ? { threadLocalId: row.threadId } : {}),
|
|
137
|
+
text: row.content,
|
|
138
|
+
contentType: row.contentType,
|
|
139
|
+
attachments: row.attachments.map((a) => ({
|
|
140
|
+
kind: a.kind,
|
|
141
|
+
mime: a.mimeType,
|
|
142
|
+
size: a.sizeBytes,
|
|
143
|
+
...(a.originalName ? { name: a.originalName } : {}),
|
|
144
|
+
})),
|
|
145
|
+
...(clientMessageId ? { clientMessageId } : {}),
|
|
146
|
+
createdAt: row.createdAt,
|
|
147
|
+
localSeq,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Bytes one message adds to a request body (JSON, before gzip).
|
|
152
|
+
*
|
|
153
|
+
* @param message - Wire message
|
|
154
|
+
* @returns Approximate byte size
|
|
155
|
+
*/
|
|
156
|
+
function sizeOf(message) {
|
|
157
|
+
return Buffer.byteLength(JSON.stringify(message), 'utf8') + 1;
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Service
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
/** Uploads the machine's conversation log to Crewly Cloud. */
|
|
163
|
+
export class ConversationCloudSyncService {
|
|
164
|
+
deps;
|
|
165
|
+
logger;
|
|
166
|
+
fetchImpl;
|
|
167
|
+
now;
|
|
168
|
+
setTimer;
|
|
169
|
+
clearTimer;
|
|
170
|
+
started = false;
|
|
171
|
+
running = false;
|
|
172
|
+
rerun = false;
|
|
173
|
+
tickTimer = null;
|
|
174
|
+
wakeTimer = null;
|
|
175
|
+
unsubscribe = null;
|
|
176
|
+
/** Consecutive failures; drives the exponential backoff. */
|
|
177
|
+
failures = 0;
|
|
178
|
+
/** No request before this time (backoff or an hour-long pause). */
|
|
179
|
+
pausedUntil = 0;
|
|
180
|
+
/** Reasons already logged, so a long pause logs once. */
|
|
181
|
+
loggedPauses = new Set();
|
|
182
|
+
/** Current live batch cap; halved on 413. */
|
|
183
|
+
batchLimit = C.BATCH_MAX_MESSAGES;
|
|
184
|
+
lastBackfillAt = 0;
|
|
185
|
+
lastNoticeAttemptAt = 0;
|
|
186
|
+
gapLogged = false;
|
|
187
|
+
/** When the roster last reached Cloud, and the capabilities it carried. */
|
|
188
|
+
lastRosterAt = 0;
|
|
189
|
+
lastCapabilitiesKey = null;
|
|
190
|
+
constructor(deps) {
|
|
191
|
+
this.deps = deps;
|
|
192
|
+
this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('ConversationCloudSync');
|
|
193
|
+
this.fetchImpl = deps.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
194
|
+
this.now = deps.now ?? Date.now;
|
|
195
|
+
this.setTimer = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
196
|
+
this.clearTimer = deps.clearTimeout ?? ((h) => clearTimeout(h));
|
|
197
|
+
}
|
|
198
|
+
/** Start the tick and the new-message wake-up. Idempotent; never throws. */
|
|
199
|
+
start() {
|
|
200
|
+
if (this.started)
|
|
201
|
+
return;
|
|
202
|
+
this.started = true;
|
|
203
|
+
if (isConversationSyncDisabled(this.deps.env ?? process.env)) {
|
|
204
|
+
this.logger.info('Conversation sync to Crewly Cloud is off (CREWLY_CONVERSATION_SYNC=0); the local log still fills');
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
this.unsubscribe = this.deps.onNewMessage?.(() => this.wake()) ?? null;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
this.unsubscribe = null;
|
|
211
|
+
}
|
|
212
|
+
this.scheduleTick();
|
|
213
|
+
this.wake(0);
|
|
214
|
+
}
|
|
215
|
+
/** Stop timers and the wake-up subscription. */
|
|
216
|
+
stop() {
|
|
217
|
+
this.started = false;
|
|
218
|
+
if (this.tickTimer)
|
|
219
|
+
this.clearTimer(this.tickTimer);
|
|
220
|
+
if (this.wakeTimer)
|
|
221
|
+
this.clearTimer(this.wakeTimer);
|
|
222
|
+
this.tickTimer = null;
|
|
223
|
+
this.wakeTimer = null;
|
|
224
|
+
try {
|
|
225
|
+
this.unsubscribe?.();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// ignore
|
|
229
|
+
}
|
|
230
|
+
this.unsubscribe = null;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Run one sync pass now (tests, and the timers). Never throws; a pass
|
|
234
|
+
* already in flight makes this one run again right after it.
|
|
235
|
+
*/
|
|
236
|
+
async syncNow() {
|
|
237
|
+
if (this.running) {
|
|
238
|
+
this.rerun = true;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
this.running = true;
|
|
242
|
+
try {
|
|
243
|
+
do {
|
|
244
|
+
this.rerun = false;
|
|
245
|
+
await this.pass();
|
|
246
|
+
} while (this.rerun);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
this.logger.warn('Conversation sync pass failed', { error: error instanceof Error ? error.message : String(error) });
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
this.running = false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Schedule a pass after a new message: immediately once a full batch is
|
|
257
|
+
* waiting, otherwise after the gather delay.
|
|
258
|
+
*
|
|
259
|
+
* @param delayMs - Override the delay
|
|
260
|
+
*/
|
|
261
|
+
wake(delayMs) {
|
|
262
|
+
if (!this.started)
|
|
263
|
+
return;
|
|
264
|
+
let delay = delayMs;
|
|
265
|
+
if (delay === undefined) {
|
|
266
|
+
let waiting = 0;
|
|
267
|
+
try {
|
|
268
|
+
waiting = this.deps.outbox.count();
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
waiting = 0;
|
|
272
|
+
}
|
|
273
|
+
delay = waiting >= this.batchLimit ? 0 : C.BATCH_MAX_WAIT_MS;
|
|
274
|
+
}
|
|
275
|
+
if (this.wakeTimer)
|
|
276
|
+
return;
|
|
277
|
+
this.wakeTimer = this.setTimer(() => {
|
|
278
|
+
this.wakeTimer = null;
|
|
279
|
+
void this.syncNow();
|
|
280
|
+
}, delay);
|
|
281
|
+
}
|
|
282
|
+
/** Keep the periodic tick going. */
|
|
283
|
+
scheduleTick() {
|
|
284
|
+
if (!this.started)
|
|
285
|
+
return;
|
|
286
|
+
this.tickTimer = this.setTimer(() => {
|
|
287
|
+
this.tickTimer = null;
|
|
288
|
+
void this.syncNow().finally(() => this.scheduleTick());
|
|
289
|
+
}, C.TICK_INTERVAL_MS);
|
|
290
|
+
}
|
|
291
|
+
/** One pass: trim, check the session, drain live, then one backfill step. */
|
|
292
|
+
async pass() {
|
|
293
|
+
this.trimOutbox();
|
|
294
|
+
if (isConversationSyncDisabled(this.deps.env ?? process.env))
|
|
295
|
+
return;
|
|
296
|
+
const token = this.deps.cloud.getToken();
|
|
297
|
+
const base = this.deps.cloud.getCloudUrl();
|
|
298
|
+
if (!token || !base)
|
|
299
|
+
return;
|
|
300
|
+
if (this.now() < this.pausedUntil)
|
|
301
|
+
return;
|
|
302
|
+
this.checkAccount(token);
|
|
303
|
+
const { instanceId, deviceName } = await this.deps.identity();
|
|
304
|
+
if (!INSTANCE_ID_PATTERN.test(instanceId)) {
|
|
305
|
+
this.pause('invalid_batch', `instance id "${instanceId}" is not accepted by Cloud`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const version = (await this.deps.crewlyVersion?.().catch(() => undefined)) ?? undefined;
|
|
309
|
+
const envelope = { instanceId, deviceName, version };
|
|
310
|
+
// The plan window comes from an empty ingest (spec §B.4); the same empty
|
|
311
|
+
// batch carries the roster when it is due.
|
|
312
|
+
const roster = await this.rosterIfDue();
|
|
313
|
+
if (!this.deps.outbox.getState(K.RETENTION_DAYS) || roster) {
|
|
314
|
+
const probe = await this.send(base, token, envelope, 'live', [], roster ?? undefined);
|
|
315
|
+
if (!this.settle(probe))
|
|
316
|
+
return;
|
|
317
|
+
if (roster) {
|
|
318
|
+
this.lastRosterAt = this.now();
|
|
319
|
+
this.lastCapabilitiesKey = JSON.stringify(roster.capabilities ?? []);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!(await this.drainLive(base, token, envelope)))
|
|
323
|
+
return;
|
|
324
|
+
await this.backfillStep(base, token, envelope);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The roster and capabilities when they are due: every
|
|
328
|
+
* {@link CLOUD_TALK_CONSTANTS.ROSTER_INTERVAL_MS}, or at once when the
|
|
329
|
+
* capabilities changed (the Talk handler starting after the uploader).
|
|
330
|
+
*
|
|
331
|
+
* @returns The fields to send, or null when nothing is due
|
|
332
|
+
*/
|
|
333
|
+
async rosterIfDue() {
|
|
334
|
+
if (!this.deps.roster && !this.deps.capabilities)
|
|
335
|
+
return null;
|
|
336
|
+
let capabilities;
|
|
337
|
+
try {
|
|
338
|
+
capabilities = this.deps.capabilities?.();
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
capabilities = undefined;
|
|
342
|
+
}
|
|
343
|
+
const capsChanged = capabilities !== undefined && JSON.stringify(capabilities) !== this.lastCapabilitiesKey;
|
|
344
|
+
if (!capsChanged && this.now() - this.lastRosterAt < CLOUD_TALK_CONSTANTS.ROSTER_INTERVAL_MS)
|
|
345
|
+
return null;
|
|
346
|
+
let roster;
|
|
347
|
+
try {
|
|
348
|
+
roster = await this.deps.roster?.();
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
this.logger.debug('Agent roster unavailable for Crewly Cloud', { error: error instanceof Error ? error.message : String(error) });
|
|
352
|
+
roster = undefined;
|
|
353
|
+
}
|
|
354
|
+
if (!roster && capabilities === undefined)
|
|
355
|
+
return null;
|
|
356
|
+
return { ...(roster ? { roster } : {}), ...(capabilities !== undefined ? { capabilities } : {}) };
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Upload every waiting outbox row, oldest first.
|
|
360
|
+
*
|
|
361
|
+
* @returns False when a request failed (the pass stops)
|
|
362
|
+
*/
|
|
363
|
+
async drainLive(base, token, envelope) {
|
|
364
|
+
for (;;) {
|
|
365
|
+
const entries = this.deps.outbox.peek(this.batchLimit);
|
|
366
|
+
if (entries.length === 0)
|
|
367
|
+
return true;
|
|
368
|
+
const rows = this.deps.outbox.getMessages(entries.filter((e) => e.op === 'upsert').map((e) => e.messageId));
|
|
369
|
+
const messages = [];
|
|
370
|
+
let bytes = 0;
|
|
371
|
+
let lastSeq = entries[0].seq - 1;
|
|
372
|
+
for (const entry of entries) {
|
|
373
|
+
let message;
|
|
374
|
+
if (entry.op === 'delete') {
|
|
375
|
+
message = { localId: entry.messageId, localSeq: entry.seq, op: 'delete' };
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
const row = rows.get(entry.messageId);
|
|
379
|
+
// Deleted since, or not for Cloud: settled without sending.
|
|
380
|
+
message = row ? toIngestUpsert(row, entry.seq) : null;
|
|
381
|
+
}
|
|
382
|
+
if (message) {
|
|
383
|
+
const size = sizeOf(message);
|
|
384
|
+
if (messages.length > 0 && bytes + size > C.BATCH_MAX_BYTES)
|
|
385
|
+
break;
|
|
386
|
+
messages.push(message);
|
|
387
|
+
bytes += size;
|
|
388
|
+
}
|
|
389
|
+
lastSeq = entry.seq;
|
|
390
|
+
}
|
|
391
|
+
if (messages.length === 0) {
|
|
392
|
+
this.deps.outbox.ackThrough(lastSeq);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const outcome = await this.send(base, token, envelope, 'live', messages);
|
|
396
|
+
if (!outcome.ok && outcome.status === 413) {
|
|
397
|
+
if (messages.length > 1) {
|
|
398
|
+
this.batchLimit = Math.max(1, Math.floor(messages.length / 2));
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
// One message Cloud will never take: drop it rather than wedge the outbox.
|
|
402
|
+
this.logger.warn('Cloud refused a message as too large; skipping it', { localId: messages[0].localId });
|
|
403
|
+
this.deps.outbox.ackThrough(lastSeq);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (!this.settle(outcome))
|
|
407
|
+
return false;
|
|
408
|
+
const acked = outcome.ok ? outcome.response.ackedThroughLocalSeq : null;
|
|
409
|
+
this.deps.outbox.ackThrough(acked === null ? lastSeq : Math.min(acked, lastSeq));
|
|
410
|
+
if (acked !== null && acked < lastSeq)
|
|
411
|
+
return true; // Cloud settled less; resume next pass.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Send one page of history when a backfill is owed (first sign-in, account
|
|
416
|
+
* switch, or a wider window after an upgrade). At most one request per
|
|
417
|
+
* second; live rows always go first.
|
|
418
|
+
*/
|
|
419
|
+
async backfillStep(base, token, envelope) {
|
|
420
|
+
const retention = Number(this.deps.outbox.getState(K.RETENTION_DAYS)) || C.DEFAULT_RETENTION_DAYS;
|
|
421
|
+
const doneFor = Number(this.deps.outbox.getState(K.BACKFILL_RETENTION_DAYS)) || 0;
|
|
422
|
+
if (this.deps.outbox.getState(K.BACKFILL_DONE_AT) && doneFor >= retention)
|
|
423
|
+
return;
|
|
424
|
+
if (this.deps.outbox.getState(K.BACKFILL_DONE_AT) && doneFor < retention) {
|
|
425
|
+
// Plan upgrade: a wider window — send it again from the start.
|
|
426
|
+
this.deps.outbox.setState(K.BACKFILL_DONE_AT, null);
|
|
427
|
+
this.deps.outbox.setState(K.BACKFILL_CURSOR, null);
|
|
428
|
+
}
|
|
429
|
+
const sinceLast = this.now() - this.lastBackfillAt;
|
|
430
|
+
if (sinceLast < C.BACKFILL_MIN_INTERVAL_MS) {
|
|
431
|
+
this.wake(C.BACKFILL_MIN_INTERVAL_MS - sinceLast);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (!this.deps.outbox.getState(K.BACKFILL_CURSOR)) {
|
|
435
|
+
try {
|
|
436
|
+
this.deps.reclassifyOwnerRows?.();
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// best effort
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const cursorRaw = parseJson(this.deps.outbox.getState(K.BACKFILL_CURSOR));
|
|
443
|
+
const cursor = cursorRaw && typeof cursorRaw.createdAt === 'number' && typeof cursorRaw.rowid === 'number' ? cursorRaw : null;
|
|
444
|
+
const since = this.now() - retention * DAY_MS;
|
|
445
|
+
const page = this.deps.outbox.backfillPage(since, cursor, Math.min(C.BACKFILL_PAGE_SIZE, INGEST_LIMITS.MAX_MESSAGES));
|
|
446
|
+
if (page.length === 0) {
|
|
447
|
+
this.deps.outbox.setState(K.BACKFILL_DONE_AT, String(this.now()));
|
|
448
|
+
this.deps.outbox.setState(K.BACKFILL_RETENTION_DAYS, String(retention));
|
|
449
|
+
this.deps.outbox.setState(K.BACKFILL_CURSOR, null);
|
|
450
|
+
this.logger.info('Conversation history backfill to Crewly Cloud finished', { retentionDays: retention });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const messages = [];
|
|
454
|
+
let bytes = 0;
|
|
455
|
+
let last = page[0];
|
|
456
|
+
for (const row of page) {
|
|
457
|
+
const message = toIngestUpsert(row, 0);
|
|
458
|
+
if (message) {
|
|
459
|
+
const size = sizeOf(message);
|
|
460
|
+
if (messages.length > 0 && bytes + size > C.BACKFILL_BATCH_MAX_BYTES)
|
|
461
|
+
break;
|
|
462
|
+
messages.push(message);
|
|
463
|
+
bytes += size;
|
|
464
|
+
}
|
|
465
|
+
last = row;
|
|
466
|
+
}
|
|
467
|
+
this.lastBackfillAt = this.now();
|
|
468
|
+
if (messages.length > 0) {
|
|
469
|
+
const outcome = await this.send(base, token, envelope, 'backfill', messages);
|
|
470
|
+
if (!this.settle(outcome))
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
this.deps.outbox.setState(K.BACKFILL_CURSOR, JSON.stringify({ createdAt: last.createdAt, rowid: last.rowid }));
|
|
474
|
+
this.wake(C.BACKFILL_MIN_INTERVAL_MS);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* POST one batch (gzip). Never throws.
|
|
478
|
+
*
|
|
479
|
+
* @returns The parsed outcome
|
|
480
|
+
*/
|
|
481
|
+
async send(base, token, envelope, mode, messages, extras) {
|
|
482
|
+
const body = {
|
|
483
|
+
...(extras?.roster ? { roster: extras.roster } : {}),
|
|
484
|
+
...(extras?.capabilities ? { capabilities: extras.capabilities } : {}),
|
|
485
|
+
instanceId: envelope.instanceId,
|
|
486
|
+
...(this.deps.homeId ? { homeId: this.deps.homeId } : {}),
|
|
487
|
+
...(envelope.version ? { crewlyVersion: envelope.version } : {}),
|
|
488
|
+
...(envelope.deviceName ? { deviceName: envelope.deviceName } : {}),
|
|
489
|
+
batchId: randomUUID(),
|
|
490
|
+
mode,
|
|
491
|
+
messages,
|
|
492
|
+
};
|
|
493
|
+
const url = `${base.replace(/\/$/, '')}${CONVERSATION_INGEST_PATH}`;
|
|
494
|
+
let response;
|
|
495
|
+
try {
|
|
496
|
+
response = await this.fetchImpl(url, {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
headers: {
|
|
499
|
+
Authorization: `Bearer ${token}`,
|
|
500
|
+
'Content-Type': 'application/json',
|
|
501
|
+
'Content-Encoding': 'gzip',
|
|
502
|
+
},
|
|
503
|
+
body: gzipSync(Buffer.from(JSON.stringify(body), 'utf8')),
|
|
504
|
+
signal: AbortSignal.timeout(C.REQUEST_TIMEOUT_MS),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
return { ok: false, status: 0, code: null, message: error instanceof Error ? error.message : String(error) };
|
|
509
|
+
}
|
|
510
|
+
const parsed = (await response.json().catch(() => null));
|
|
511
|
+
if (response.ok)
|
|
512
|
+
return { ok: true, response: parseIngestResponse(parsed) };
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
status: response.status,
|
|
516
|
+
code: parseIngestErrorCode(parsed),
|
|
517
|
+
message: `HTTP ${response.status}`,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Book the result of a request: remember the retention window, reset or
|
|
522
|
+
* advance the backoff, pause on the hour-long conditions.
|
|
523
|
+
*
|
|
524
|
+
* @returns True on success
|
|
525
|
+
*/
|
|
526
|
+
settle(outcome) {
|
|
527
|
+
if (outcome.ok) {
|
|
528
|
+
this.failures = 0;
|
|
529
|
+
this.pausedUntil = 0;
|
|
530
|
+
this.batchLimit = C.BATCH_MAX_MESSAGES;
|
|
531
|
+
if (this.loggedPauses.size > 0) {
|
|
532
|
+
this.logger.info('Crewly Cloud is taking conversation history again');
|
|
533
|
+
this.loggedPauses.clear();
|
|
534
|
+
}
|
|
535
|
+
const days = outcome.response.retentionDays;
|
|
536
|
+
if (days !== null && days > 0)
|
|
537
|
+
this.deps.outbox.setState(K.RETENTION_DAYS, String(days));
|
|
538
|
+
this.deps.outbox.setState(K.LAST_INGEST_AT, String(this.now()));
|
|
539
|
+
void this.maybeNotifyOwner();
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
const { status, code } = outcome;
|
|
543
|
+
if (status === 404) {
|
|
544
|
+
this.pause('not_deployed', 'Crewly Cloud has no conversation store yet; checking again in an hour');
|
|
545
|
+
}
|
|
546
|
+
else if (status === 403 && code === INGEST_ERROR_CODES.SYNC_DISABLED) {
|
|
547
|
+
this.pause('sync_disabled', 'Conversation sync is switched off for this Crewly Cloud account; checking again in an hour');
|
|
548
|
+
}
|
|
549
|
+
else if (status === 503 && code === INGEST_ERROR_CODES.KEY_MISSING) {
|
|
550
|
+
this.pause('key_missing', 'Crewly Cloud cannot store conversations right now (key missing); checking again in an hour');
|
|
551
|
+
}
|
|
552
|
+
else if (status === 400) {
|
|
553
|
+
this.pause('invalid_batch', `Crewly Cloud refused a conversation batch (${code ?? 'invalid_batch'}); checking again in an hour`);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
if (status === 401)
|
|
557
|
+
void this.deps.cloud.tryRefreshToken?.().catch(() => false);
|
|
558
|
+
this.failures += 1;
|
|
559
|
+
const delay = Math.min(C.BACKOFF_INITIAL_MS * 2 ** (this.failures - 1), C.BACKOFF_MAX_MS);
|
|
560
|
+
this.pausedUntil = this.now() + delay;
|
|
561
|
+
if (this.failures === 1 || delay === C.BACKOFF_MAX_MS) {
|
|
562
|
+
this.logger.warn('Conversation upload failed; will retry', { status, code, error: outcome.message, retryInMs: delay });
|
|
563
|
+
}
|
|
564
|
+
this.wake(delay);
|
|
565
|
+
}
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Pause for an hour, logging the reason once.
|
|
570
|
+
*
|
|
571
|
+
* @param reason - Why
|
|
572
|
+
* @param message - Log line
|
|
573
|
+
*/
|
|
574
|
+
pause(reason, message) {
|
|
575
|
+
this.pausedUntil = this.now() + C.UNAVAILABLE_RETRY_MS;
|
|
576
|
+
if (!this.loggedPauses.has(reason)) {
|
|
577
|
+
this.loggedPauses.add(reason);
|
|
578
|
+
this.logger.info(message);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* On an account switch, start over: the new account gets this machine's
|
|
583
|
+
* history through a fresh backfill, never the old account's queue.
|
|
584
|
+
*
|
|
585
|
+
* @param token - Current access token
|
|
586
|
+
*/
|
|
587
|
+
checkAccount(token) {
|
|
588
|
+
const account = accountIdOfToken(token);
|
|
589
|
+
const stored = this.deps.outbox.getState(K.ACCOUNT_ID);
|
|
590
|
+
if (stored === account)
|
|
591
|
+
return;
|
|
592
|
+
if (stored !== null) {
|
|
593
|
+
this.logger.info('Crewly Cloud account changed; conversation history will be sent again for the new account');
|
|
594
|
+
this.deps.outbox.clear();
|
|
595
|
+
this.deps.outbox.clearState([K.NOTICE_SENT_AT]);
|
|
596
|
+
}
|
|
597
|
+
this.deps.outbox.setState(K.ACCOUNT_ID, account);
|
|
598
|
+
}
|
|
599
|
+
/** Keep the outbox bounded; log a history gap once. */
|
|
600
|
+
trimOutbox() {
|
|
601
|
+
try {
|
|
602
|
+
const dropped = this.deps.outbox.trim({ maxRows: C.OUTBOX_MAX_ROWS, olderThanMs: this.now() - C.OUTBOX_MAX_AGE_MS });
|
|
603
|
+
if (dropped > 0) {
|
|
604
|
+
this.deps.outbox.setState(K.GAP_AT, String(this.now()));
|
|
605
|
+
if (!this.gapLogged) {
|
|
606
|
+
this.gapLogged = true;
|
|
607
|
+
this.logger.warn('Conversation outbox over its limit; oldest rows dropped (history gap on Crewly Cloud)', { dropped });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// trimming is housekeeping
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/** O1: one DM to the owner the first time history reaches Cloud. */
|
|
616
|
+
async maybeNotifyOwner() {
|
|
617
|
+
if (!this.deps.notifyOwner)
|
|
618
|
+
return;
|
|
619
|
+
if (this.deps.outbox.getState(K.NOTICE_SENT_AT))
|
|
620
|
+
return;
|
|
621
|
+
if (this.now() - this.lastNoticeAttemptAt < C.UNAVAILABLE_RETRY_MS && this.lastNoticeAttemptAt > 0)
|
|
622
|
+
return;
|
|
623
|
+
this.lastNoticeAttemptAt = this.now();
|
|
624
|
+
// Claim it first so two quick successes cannot send two DMs.
|
|
625
|
+
this.deps.outbox.setState(K.NOTICE_SENT_AT, String(this.now()));
|
|
626
|
+
let posted = false;
|
|
627
|
+
try {
|
|
628
|
+
const { deviceName } = await this.deps.identity();
|
|
629
|
+
posted = await this.deps.notifyOwner(C.NOTICE_TEXT.replace('{device}', deviceName || 'this machine'));
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
this.logger.debug('Conversation sync notice not sent', { error: error instanceof Error ? error.message : String(error) });
|
|
633
|
+
}
|
|
634
|
+
if (!posted)
|
|
635
|
+
this.deps.outbox.setState(K.NOTICE_SENT_AT, null);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
let instance = null;
|
|
639
|
+
/**
|
|
640
|
+
* Install the process-wide instance (composition root).
|
|
641
|
+
*
|
|
642
|
+
* @param service - The service, or null
|
|
643
|
+
*/
|
|
644
|
+
export function setConversationCloudSyncService(service) {
|
|
645
|
+
instance = service;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The process-wide instance, or null before wiring.
|
|
649
|
+
*
|
|
650
|
+
* @returns The service or null
|
|
651
|
+
*/
|
|
652
|
+
export function getConversationCloudSyncService() {
|
|
653
|
+
return instance;
|
|
654
|
+
}
|
|
655
|
+
//# sourceMappingURL=conversation-cloud-sync.service.js.map
|