crewly 1.4.80 → 1.4.82
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 +24 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +24 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts +1 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +88 -1
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/index.d.ts +1 -0
- package/dist/backend/backend/src/services/messaging/index.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/index.js +1 -0
- package/dist/backend/backend/src/services/messaging/index.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/queue-processor.service.d.ts +9 -0
- package/dist/backend/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/queue-processor.service.js +27 -0
- package/dist/backend/backend/src/services/messaging/queue-processor.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/response-router.service.d.ts +18 -0
- package/dist/backend/backend/src/services/messaging/response-router.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/response-router.service.js +62 -0
- package/dist/backend/backend/src/services/messaging/response-router.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/thread-status-queue.service.d.ts +197 -0
- package/dist/backend/backend/src/services/messaging/thread-status-queue.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/messaging/thread-status-queue.service.js +458 -0
- package/dist/backend/backend/src/services/messaging/thread-status-queue.service.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +9 -0
- 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 +59 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/types/index.d.ts +1 -0
- package/dist/backend/backend/src/types/index.d.ts.map +1 -1
- package/dist/backend/backend/src/types/index.js +2 -0
- package/dist/backend/backend/src/types/index.js.map +1 -1
- package/dist/backend/backend/src/types/thread-status.types.d.ts +165 -0
- package/dist/backend/backend/src/types/thread-status.types.d.ts.map +1 -0
- package/dist/backend/backend/src/types/thread-status.types.js +105 -0
- package/dist/backend/backend/src/types/thread-status.types.js.map +1 -0
- package/dist/cli/backend/src/constants.d.ts +24 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +24 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/types/index.d.ts +1 -0
- package/dist/cli/backend/src/types/index.d.ts.map +1 -1
- package/dist/cli/backend/src/types/index.js +2 -0
- package/dist/cli/backend/src/types/index.js.map +1 -1
- package/dist/cli/backend/src/types/thread-status.types.d.ts +165 -0
- package/dist/cli/backend/src/types/thread-status.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/thread-status.types.js +105 -0
- package/dist/cli/backend/src/types/thread-status.types.js.map +1 -0
- package/frontend/dist/assets/{index-e2a673d6.css → index-975ccc95.css} +1 -1
- package/frontend/dist/assets/{index-e830dc67.js → index-d28d1135.js} +331 -331
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"response-router.service.js","sourceRoot":"","sources":["../../../../../../backend/src/services/messaging/response-router.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAmB,MAAM,2BAA2B,CAAC;AAC3E,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"response-router.service.js","sourceRoot":"","sources":["../../../../../../backend/src/services/messaging/response-router.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread Status Queue Service
|
|
3
|
+
*
|
|
4
|
+
* Tracks the lifecycle of every inbound message thread across platforms
|
|
5
|
+
* (Slack, GChat, Telegram, etc.). On orchestrator restart, non-terminal
|
|
6
|
+
* threads are recovered and re-enqueued so no user messages are silently dropped.
|
|
7
|
+
*
|
|
8
|
+
* Persistence is debounced and written to ~/.crewly/thread-status-queue.json
|
|
9
|
+
* using atomic file I/O to prevent corruption.
|
|
10
|
+
*
|
|
11
|
+
* @module services/messaging/thread-status-queue
|
|
12
|
+
*/
|
|
13
|
+
import type { ThreadStatus, ThreadStatusEntry, TrackInboundInput, ThreadStatusStats, ReplyStatus } from '../../types/thread-status.types.js';
|
|
14
|
+
/**
|
|
15
|
+
* Service that maintains a persistent queue of thread statuses.
|
|
16
|
+
* Constructed with a crewly home directory path for disk persistence.
|
|
17
|
+
*
|
|
18
|
+
* Lifecycle: enqueued → delivered → replied_* (terminal or waiting_actions)
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const svc = new ThreadStatusQueueService('/home/user/.crewly');
|
|
23
|
+
* await svc.loadPersistedState();
|
|
24
|
+
*
|
|
25
|
+
* const entry = svc.trackInbound({
|
|
26
|
+
* source: 'slack',
|
|
27
|
+
* threadKey: 'C123:170743.001',
|
|
28
|
+
* conversationId: 'conv-1',
|
|
29
|
+
* messagePreview: 'Deploy the new feature',
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* svc.markDelivered('C123:170743.001');
|
|
33
|
+
* svc.markReplied('C123:170743.001', 'replied_completed');
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare class ThreadStatusQueueService {
|
|
37
|
+
/** Singleton instance */
|
|
38
|
+
private static instance;
|
|
39
|
+
/** Logger for this service */
|
|
40
|
+
private logger;
|
|
41
|
+
/** All tracked thread entries, keyed by threadKey for O(1) lookup */
|
|
42
|
+
private entries;
|
|
43
|
+
/** Path to the persistence file, or null if init() has not been called */
|
|
44
|
+
private persistPath;
|
|
45
|
+
/** Debounce timer for batching persistence writes */
|
|
46
|
+
private persistTimer;
|
|
47
|
+
/** ISO timestamp of last cleanup run */
|
|
48
|
+
private lastCleanupAt;
|
|
49
|
+
/**
|
|
50
|
+
* Creates a new ThreadStatusQueueService.
|
|
51
|
+
* Use getInstance() for singleton access, or construct directly for testing.
|
|
52
|
+
*
|
|
53
|
+
* @param crewlyHome - Optional crewly home directory path for immediate initialization
|
|
54
|
+
*/
|
|
55
|
+
constructor(crewlyHome?: string);
|
|
56
|
+
/**
|
|
57
|
+
* Returns the singleton instance of ThreadStatusQueueService.
|
|
58
|
+
* Call init() after this to set the persistence path.
|
|
59
|
+
*
|
|
60
|
+
* @returns The singleton instance
|
|
61
|
+
*/
|
|
62
|
+
static getInstance(): ThreadStatusQueueService;
|
|
63
|
+
/**
|
|
64
|
+
* Resets the singleton instance. For testing only.
|
|
65
|
+
*/
|
|
66
|
+
static resetInstance(): void;
|
|
67
|
+
/**
|
|
68
|
+
* Loads persisted state from disk.
|
|
69
|
+
* If the file doesn't exist or is corrupted, starts with an empty queue.
|
|
70
|
+
*/
|
|
71
|
+
loadPersistedState(): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Records a new inbound thread message with status `enqueued`.
|
|
74
|
+
* If a thread with the same threadKey already exists, the existing entry is returned.
|
|
75
|
+
*
|
|
76
|
+
* @param input - Inbound message details
|
|
77
|
+
* @returns The created (or existing) ThreadStatusEntry
|
|
78
|
+
*/
|
|
79
|
+
trackInbound(input: TrackInboundInput): ThreadStatusEntry;
|
|
80
|
+
/**
|
|
81
|
+
* Transitions a thread from `enqueued` (or `error`) to `delivered`.
|
|
82
|
+
* No-op if thread is already in a terminal or later status.
|
|
83
|
+
*
|
|
84
|
+
* @param threadKey - Platform-specific thread identifier
|
|
85
|
+
* @throws Error if threadKey is not found
|
|
86
|
+
*/
|
|
87
|
+
markDelivered(threadKey: string): void;
|
|
88
|
+
/**
|
|
89
|
+
* Transitions a thread to one of the replied_* statuses.
|
|
90
|
+
* Sets the repliedAt timestamp.
|
|
91
|
+
*
|
|
92
|
+
* @param threadKey - Platform-specific thread identifier
|
|
93
|
+
* @param status - One of: replied_completed, replied_waiting_actions, replied_to_follow_up
|
|
94
|
+
* @throws Error if threadKey is not found or status is not a valid reply status
|
|
95
|
+
*/
|
|
96
|
+
markReplied(threadKey: string, status: ReplyStatus): void;
|
|
97
|
+
/**
|
|
98
|
+
* Records that an agent was delegated work from this thread.
|
|
99
|
+
*
|
|
100
|
+
* @param threadKey - Platform-specific thread identifier
|
|
101
|
+
* @param agentSession - Session name of the delegated agent
|
|
102
|
+
* @throws Error if threadKey is not found
|
|
103
|
+
*/
|
|
104
|
+
addDelegatedAgent(threadKey: string, agentSession: string): void;
|
|
105
|
+
/**
|
|
106
|
+
* Transitions a `replied_waiting_actions` thread to `replied_completed`
|
|
107
|
+
* when all delegated agents have finished their work.
|
|
108
|
+
*
|
|
109
|
+
* @param threadKey - Platform-specific thread identifier
|
|
110
|
+
* @throws Error if threadKey is not found
|
|
111
|
+
*/
|
|
112
|
+
markDelegationsComplete(threadKey: string): void;
|
|
113
|
+
/**
|
|
114
|
+
* Returns all entries not in a terminal status. Used for restart recovery.
|
|
115
|
+
*
|
|
116
|
+
* @returns Array of non-terminal ThreadStatusEntry objects
|
|
117
|
+
*/
|
|
118
|
+
getPendingThreads(): ThreadStatusEntry[];
|
|
119
|
+
/**
|
|
120
|
+
* Returns all entries matching the given status.
|
|
121
|
+
*
|
|
122
|
+
* @param status - The ThreadStatus to filter by
|
|
123
|
+
* @returns Array of matching ThreadStatusEntry objects
|
|
124
|
+
*/
|
|
125
|
+
getByStatus(status: ThreadStatus): ThreadStatusEntry[];
|
|
126
|
+
/**
|
|
127
|
+
* Retrieves a specific entry by threadKey.
|
|
128
|
+
*
|
|
129
|
+
* @param threadKey - Platform-specific thread identifier
|
|
130
|
+
* @returns The entry, or null if not found
|
|
131
|
+
*/
|
|
132
|
+
get(threadKey: string): ThreadStatusEntry | null;
|
|
133
|
+
/**
|
|
134
|
+
* Expires entries that have been in a non-terminal status longer than maxAgeMinutes.
|
|
135
|
+
* Transitions them to `expired`.
|
|
136
|
+
*
|
|
137
|
+
* @param maxAgeMinutes - Maximum age in minutes (defaults to STALE_TIMEOUT_MINUTES)
|
|
138
|
+
* @returns Number of entries expired
|
|
139
|
+
*/
|
|
140
|
+
expireStale(maxAgeMinutes?: number): number;
|
|
141
|
+
/**
|
|
142
|
+
* Removes terminal entries older than retentionHours.
|
|
143
|
+
* Also enforces MAX_ENTRIES by pruning oldest terminal entries first.
|
|
144
|
+
*
|
|
145
|
+
* @param retentionHours - Hours to retain terminal entries (defaults to CLEANUP_RETENTION_HOURS)
|
|
146
|
+
* @returns Number of entries removed
|
|
147
|
+
*/
|
|
148
|
+
cleanup(retentionHours?: number): number;
|
|
149
|
+
/**
|
|
150
|
+
* Returns statistics for monitoring the thread status queue.
|
|
151
|
+
*
|
|
152
|
+
* @returns Stats snapshot including total, per-status counts, and oldest pending timestamp
|
|
153
|
+
*/
|
|
154
|
+
getStats(): ThreadStatusStats;
|
|
155
|
+
/**
|
|
156
|
+
* Initializes the service with a crewly home directory for persistence.
|
|
157
|
+
* Must be called before loadPersistedState() when using getInstance().
|
|
158
|
+
*
|
|
159
|
+
* @param crewlyHome - Absolute path to the crewly home directory (e.g. ~/.crewly)
|
|
160
|
+
*/
|
|
161
|
+
init(crewlyHome: string): void;
|
|
162
|
+
/**
|
|
163
|
+
* Public convenience method: optionally re-inits the persist path,
|
|
164
|
+
* then loads persisted state from disk.
|
|
165
|
+
*
|
|
166
|
+
* @param crewlyHome - Optional crewly home directory to re-initialize with
|
|
167
|
+
*/
|
|
168
|
+
load(crewlyHome?: string): Promise<void>;
|
|
169
|
+
/**
|
|
170
|
+
* Immediately flushes the current state to disk. Call during graceful shutdown
|
|
171
|
+
* to ensure no in-memory mutations are lost.
|
|
172
|
+
*/
|
|
173
|
+
persist(): Promise<void>;
|
|
174
|
+
/**
|
|
175
|
+
* Cleans up timers. Call on shutdown.
|
|
176
|
+
*/
|
|
177
|
+
destroy(): void;
|
|
178
|
+
/**
|
|
179
|
+
* Retrieves an entry by threadKey or throws an error with a descriptive message.
|
|
180
|
+
*
|
|
181
|
+
* @param threadKey - Platform-specific thread identifier
|
|
182
|
+
* @param caller - Name of the calling method (for error messages)
|
|
183
|
+
* @returns The found entry
|
|
184
|
+
* @throws Error if entry is not found
|
|
185
|
+
*/
|
|
186
|
+
private getOrThrow;
|
|
187
|
+
/**
|
|
188
|
+
* Schedules a debounced persistence write to disk.
|
|
189
|
+
* Multiple mutations within PERSIST_DEBOUNCE_MS are batched into a single write.
|
|
190
|
+
*/
|
|
191
|
+
private schedulePersist;
|
|
192
|
+
/**
|
|
193
|
+
* Writes the current queue state to disk atomically.
|
|
194
|
+
*/
|
|
195
|
+
private persistToDisk;
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=thread-status-queue.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thread-status-queue.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/messaging/thread-status-queue.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAaH,OAAO,KAAK,EACV,YAAY,EACZ,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,EAEjB,WAAW,EACZ,MAAM,oCAAoC,CAAC;AAE5C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,wBAAwB;IACnC,yBAAyB;IACzB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAA2B;IAElD,8BAA8B;IAC9B,OAAO,CAAC,MAAM,CAAkG;IAEhH,qEAAqE;IACrE,OAAO,CAAC,OAAO,CAA6C;IAE5D,0EAA0E;IAC1E,OAAO,CAAC,WAAW,CAAuB;IAE1C,qDAAqD;IACrD,OAAO,CAAC,YAAY,CAA8C;IAElE,wCAAwC;IACxC,OAAO,CAAC,aAAa,CAAoC;IAEzD;;;;;OAKG;gBACS,UAAU,CAAC,EAAE,MAAM;IAO/B;;;;;OAKG;IACH,MAAM,CAAC,WAAW,IAAI,wBAAwB;IAO9C;;OAEG;IACH,MAAM,CAAC,aAAa,IAAI,IAAI;IAO5B;;;OAGG;IACG,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IAmCzC;;;;;;OAMG;IACH,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,iBAAiB;IAkCzD;;;;;;OAMG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAetC;;;;;;;OAOG;IACH,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,IAAI;IAmBzD;;;;;;OAMG;IACH,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAgBhE;;;;;;OAMG;IACH,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAahD;;;;OAIG;IACH,iBAAiB,IAAI,iBAAiB,EAAE;IAUxC;;;;;OAKG;IACH,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,iBAAiB,EAAE;IAUtD;;;;;OAKG;IACH,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI;IAIhD;;;;;;OAMG;IACH,WAAW,CAAC,aAAa,GAAE,MAAsD,GAAG,MAAM;IAuB1F;;;;;;OAMG;IACH,OAAO,CAAC,cAAc,GAAE,MAAwD,GAAG,MAAM;IAqCzF;;;;OAIG;IACH,QAAQ,IAAI,iBAAiB;IA8B7B;;;;;OAKG;IACH,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAK9B;;;;;OAKG;IACG,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO9C;;;OAGG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAS9B;;OAEG;IACH,OAAO,IAAI,IAAI;IAWf;;;;;;;OAOG;IACH,OAAO,CAAC,UAAU;IAQlB;;;OAGG;IACH,OAAO,CAAC,eAAe;IAevB;;OAEG;YACW,aAAa;CAe5B"}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread Status Queue Service
|
|
3
|
+
*
|
|
4
|
+
* Tracks the lifecycle of every inbound message thread across platforms
|
|
5
|
+
* (Slack, GChat, Telegram, etc.). On orchestrator restart, non-terminal
|
|
6
|
+
* threads are recovered and re-enqueued so no user messages are silently dropped.
|
|
7
|
+
*
|
|
8
|
+
* Persistence is debounced and written to ~/.crewly/thread-status-queue.json
|
|
9
|
+
* using atomic file I/O to prevent corruption.
|
|
10
|
+
*
|
|
11
|
+
* @module services/messaging/thread-status-queue
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'crypto';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { THREAD_STATUS_CONSTANTS } from '../../constants.js';
|
|
16
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
17
|
+
import { atomicWriteFile, safeReadJson } from '../../utils/file-io.utils.js';
|
|
18
|
+
import { PERSISTED_THREAD_STATUS_VERSION, isPersistedThreadStatusState, isTerminalStatus, isReplyStatus, } from '../../types/thread-status.types.js';
|
|
19
|
+
/**
|
|
20
|
+
* Service that maintains a persistent queue of thread statuses.
|
|
21
|
+
* Constructed with a crewly home directory path for disk persistence.
|
|
22
|
+
*
|
|
23
|
+
* Lifecycle: enqueued → delivered → replied_* (terminal or waiting_actions)
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const svc = new ThreadStatusQueueService('/home/user/.crewly');
|
|
28
|
+
* await svc.loadPersistedState();
|
|
29
|
+
*
|
|
30
|
+
* const entry = svc.trackInbound({
|
|
31
|
+
* source: 'slack',
|
|
32
|
+
* threadKey: 'C123:170743.001',
|
|
33
|
+
* conversationId: 'conv-1',
|
|
34
|
+
* messagePreview: 'Deploy the new feature',
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* svc.markDelivered('C123:170743.001');
|
|
38
|
+
* svc.markReplied('C123:170743.001', 'replied_completed');
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class ThreadStatusQueueService {
|
|
42
|
+
/** Singleton instance */
|
|
43
|
+
static instance;
|
|
44
|
+
/** Logger for this service */
|
|
45
|
+
logger = LoggerService.getInstance().createComponentLogger('ThreadStatusQueueService');
|
|
46
|
+
/** All tracked thread entries, keyed by threadKey for O(1) lookup */
|
|
47
|
+
entries = new Map();
|
|
48
|
+
/** Path to the persistence file, or null if init() has not been called */
|
|
49
|
+
persistPath = null;
|
|
50
|
+
/** Debounce timer for batching persistence writes */
|
|
51
|
+
persistTimer = null;
|
|
52
|
+
/** ISO timestamp of last cleanup run */
|
|
53
|
+
lastCleanupAt = new Date().toISOString();
|
|
54
|
+
/**
|
|
55
|
+
* Creates a new ThreadStatusQueueService.
|
|
56
|
+
* Use getInstance() for singleton access, or construct directly for testing.
|
|
57
|
+
*
|
|
58
|
+
* @param crewlyHome - Optional crewly home directory path for immediate initialization
|
|
59
|
+
*/
|
|
60
|
+
constructor(crewlyHome) {
|
|
61
|
+
if (crewlyHome) {
|
|
62
|
+
this.persistPath = path.join(crewlyHome, THREAD_STATUS_CONSTANTS.STORAGE_FILE);
|
|
63
|
+
this.logger.info('Initialized', { persistPath: this.persistPath });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Returns the singleton instance of ThreadStatusQueueService.
|
|
68
|
+
* Call init() after this to set the persistence path.
|
|
69
|
+
*
|
|
70
|
+
* @returns The singleton instance
|
|
71
|
+
*/
|
|
72
|
+
static getInstance() {
|
|
73
|
+
if (!ThreadStatusQueueService.instance) {
|
|
74
|
+
ThreadStatusQueueService.instance = new ThreadStatusQueueService();
|
|
75
|
+
}
|
|
76
|
+
return ThreadStatusQueueService.instance;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resets the singleton instance. For testing only.
|
|
80
|
+
*/
|
|
81
|
+
static resetInstance() {
|
|
82
|
+
if (ThreadStatusQueueService.instance) {
|
|
83
|
+
ThreadStatusQueueService.instance.destroy();
|
|
84
|
+
}
|
|
85
|
+
ThreadStatusQueueService.instance = undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Loads persisted state from disk.
|
|
89
|
+
* If the file doesn't exist or is corrupted, starts with an empty queue.
|
|
90
|
+
*/
|
|
91
|
+
async loadPersistedState() {
|
|
92
|
+
if (!this.persistPath) {
|
|
93
|
+
this.logger.warn('loadPersistedState called before init — skipping');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const defaultState = {
|
|
97
|
+
version: PERSISTED_THREAD_STATUS_VERSION,
|
|
98
|
+
entries: [],
|
|
99
|
+
lastCleanupAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
const state = await safeReadJson(this.persistPath, defaultState, this.logger);
|
|
102
|
+
if (!isPersistedThreadStatusState(state)) {
|
|
103
|
+
this.logger.warn('Invalid persisted state, starting fresh');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.entries.clear();
|
|
107
|
+
for (const entry of state.entries) {
|
|
108
|
+
this.entries.set(entry.threadKey, entry);
|
|
109
|
+
}
|
|
110
|
+
this.lastCleanupAt = state.lastCleanupAt;
|
|
111
|
+
this.logger.info('Loaded persisted state', {
|
|
112
|
+
entryCount: this.entries.size,
|
|
113
|
+
lastCleanupAt: this.lastCleanupAt,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Records a new inbound thread message with status `enqueued`.
|
|
118
|
+
* If a thread with the same threadKey already exists, the existing entry is returned.
|
|
119
|
+
*
|
|
120
|
+
* @param input - Inbound message details
|
|
121
|
+
* @returns The created (or existing) ThreadStatusEntry
|
|
122
|
+
*/
|
|
123
|
+
trackInbound(input) {
|
|
124
|
+
const existing = this.entries.get(input.threadKey);
|
|
125
|
+
if (existing) {
|
|
126
|
+
this.logger.debug('Thread already tracked, returning existing', { threadKey: input.threadKey });
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
const now = new Date().toISOString();
|
|
130
|
+
const entry = {
|
|
131
|
+
id: randomUUID(),
|
|
132
|
+
source: input.source,
|
|
133
|
+
threadKey: input.threadKey,
|
|
134
|
+
conversationId: input.conversationId,
|
|
135
|
+
status: 'enqueued',
|
|
136
|
+
receivedAt: now,
|
|
137
|
+
updatedAt: now,
|
|
138
|
+
messagePreview: input.messagePreview.slice(0, THREAD_STATUS_CONSTANTS.MAX_PREVIEW_LENGTH),
|
|
139
|
+
retryCount: 0,
|
|
140
|
+
queueMessageId: input.queueMessageId,
|
|
141
|
+
sourceMetadata: input.sourceMetadata,
|
|
142
|
+
};
|
|
143
|
+
this.entries.set(input.threadKey, entry);
|
|
144
|
+
this.schedulePersist();
|
|
145
|
+
this.logger.info('Tracked inbound thread', {
|
|
146
|
+
threadKey: input.threadKey,
|
|
147
|
+
source: input.source,
|
|
148
|
+
conversationId: input.conversationId,
|
|
149
|
+
});
|
|
150
|
+
return entry;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Transitions a thread from `enqueued` (or `error`) to `delivered`.
|
|
154
|
+
* No-op if thread is already in a terminal or later status.
|
|
155
|
+
*
|
|
156
|
+
* @param threadKey - Platform-specific thread identifier
|
|
157
|
+
* @throws Error if threadKey is not found
|
|
158
|
+
*/
|
|
159
|
+
markDelivered(threadKey) {
|
|
160
|
+
const entry = this.getOrThrow(threadKey, 'markDelivered');
|
|
161
|
+
if (isTerminalStatus(entry.status) || entry.status === 'delivered') {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const now = new Date().toISOString();
|
|
165
|
+
entry.status = 'delivered';
|
|
166
|
+
entry.deliveredAt = now;
|
|
167
|
+
entry.updatedAt = now;
|
|
168
|
+
this.schedulePersist();
|
|
169
|
+
this.logger.info('Marked delivered', { threadKey });
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Transitions a thread to one of the replied_* statuses.
|
|
173
|
+
* Sets the repliedAt timestamp.
|
|
174
|
+
*
|
|
175
|
+
* @param threadKey - Platform-specific thread identifier
|
|
176
|
+
* @param status - One of: replied_completed, replied_waiting_actions, replied_to_follow_up
|
|
177
|
+
* @throws Error if threadKey is not found or status is not a valid reply status
|
|
178
|
+
*/
|
|
179
|
+
markReplied(threadKey, status) {
|
|
180
|
+
if (!isReplyStatus(status)) {
|
|
181
|
+
throw new Error(`Invalid reply status: ${status}`);
|
|
182
|
+
}
|
|
183
|
+
const entry = this.getOrThrow(threadKey, 'markReplied');
|
|
184
|
+
if (isTerminalStatus(entry.status)) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const now = new Date().toISOString();
|
|
188
|
+
entry.status = status;
|
|
189
|
+
entry.repliedAt = now;
|
|
190
|
+
entry.updatedAt = now;
|
|
191
|
+
this.schedulePersist();
|
|
192
|
+
this.logger.info('Marked replied', { threadKey, status });
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Records that an agent was delegated work from this thread.
|
|
196
|
+
*
|
|
197
|
+
* @param threadKey - Platform-specific thread identifier
|
|
198
|
+
* @param agentSession - Session name of the delegated agent
|
|
199
|
+
* @throws Error if threadKey is not found
|
|
200
|
+
*/
|
|
201
|
+
addDelegatedAgent(threadKey, agentSession) {
|
|
202
|
+
const entry = this.getOrThrow(threadKey, 'addDelegatedAgent');
|
|
203
|
+
if (!entry.delegatedAgents) {
|
|
204
|
+
entry.delegatedAgents = [];
|
|
205
|
+
}
|
|
206
|
+
if (!entry.delegatedAgents.includes(agentSession)) {
|
|
207
|
+
entry.delegatedAgents.push(agentSession);
|
|
208
|
+
entry.updatedAt = new Date().toISOString();
|
|
209
|
+
this.schedulePersist();
|
|
210
|
+
this.logger.info('Added delegated agent', { threadKey, agentSession });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Transitions a `replied_waiting_actions` thread to `replied_completed`
|
|
215
|
+
* when all delegated agents have finished their work.
|
|
216
|
+
*
|
|
217
|
+
* @param threadKey - Platform-specific thread identifier
|
|
218
|
+
* @throws Error if threadKey is not found
|
|
219
|
+
*/
|
|
220
|
+
markDelegationsComplete(threadKey) {
|
|
221
|
+
const entry = this.getOrThrow(threadKey, 'markDelegationsComplete');
|
|
222
|
+
if (entry.status !== 'replied_waiting_actions') {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
entry.status = 'replied_completed';
|
|
226
|
+
entry.updatedAt = new Date().toISOString();
|
|
227
|
+
this.schedulePersist();
|
|
228
|
+
this.logger.info('Marked delegations complete', { threadKey });
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Returns all entries not in a terminal status. Used for restart recovery.
|
|
232
|
+
*
|
|
233
|
+
* @returns Array of non-terminal ThreadStatusEntry objects
|
|
234
|
+
*/
|
|
235
|
+
getPendingThreads() {
|
|
236
|
+
const pending = [];
|
|
237
|
+
for (const entry of this.entries.values()) {
|
|
238
|
+
if (!isTerminalStatus(entry.status)) {
|
|
239
|
+
pending.push(entry);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return pending;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Returns all entries matching the given status.
|
|
246
|
+
*
|
|
247
|
+
* @param status - The ThreadStatus to filter by
|
|
248
|
+
* @returns Array of matching ThreadStatusEntry objects
|
|
249
|
+
*/
|
|
250
|
+
getByStatus(status) {
|
|
251
|
+
const results = [];
|
|
252
|
+
for (const entry of this.entries.values()) {
|
|
253
|
+
if (entry.status === status) {
|
|
254
|
+
results.push(entry);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Retrieves a specific entry by threadKey.
|
|
261
|
+
*
|
|
262
|
+
* @param threadKey - Platform-specific thread identifier
|
|
263
|
+
* @returns The entry, or null if not found
|
|
264
|
+
*/
|
|
265
|
+
get(threadKey) {
|
|
266
|
+
return this.entries.get(threadKey) ?? null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Expires entries that have been in a non-terminal status longer than maxAgeMinutes.
|
|
270
|
+
* Transitions them to `expired`.
|
|
271
|
+
*
|
|
272
|
+
* @param maxAgeMinutes - Maximum age in minutes (defaults to STALE_TIMEOUT_MINUTES)
|
|
273
|
+
* @returns Number of entries expired
|
|
274
|
+
*/
|
|
275
|
+
expireStale(maxAgeMinutes = THREAD_STATUS_CONSTANTS.STALE_TIMEOUT_MINUTES) {
|
|
276
|
+
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
277
|
+
let count = 0;
|
|
278
|
+
for (const entry of this.entries.values()) {
|
|
279
|
+
if (isTerminalStatus(entry.status))
|
|
280
|
+
continue;
|
|
281
|
+
const updatedTime = new Date(entry.updatedAt).getTime();
|
|
282
|
+
if (updatedTime < cutoff) {
|
|
283
|
+
entry.status = 'expired';
|
|
284
|
+
entry.updatedAt = new Date().toISOString();
|
|
285
|
+
count++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (count > 0) {
|
|
289
|
+
this.schedulePersist();
|
|
290
|
+
this.logger.info('Expired stale threads', { count, maxAgeMinutes });
|
|
291
|
+
}
|
|
292
|
+
return count;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Removes terminal entries older than retentionHours.
|
|
296
|
+
* Also enforces MAX_ENTRIES by pruning oldest terminal entries first.
|
|
297
|
+
*
|
|
298
|
+
* @param retentionHours - Hours to retain terminal entries (defaults to CLEANUP_RETENTION_HOURS)
|
|
299
|
+
* @returns Number of entries removed
|
|
300
|
+
*/
|
|
301
|
+
cleanup(retentionHours = THREAD_STATUS_CONSTANTS.CLEANUP_RETENTION_HOURS) {
|
|
302
|
+
const cutoff = Date.now() - retentionHours * 60 * 60 * 1000;
|
|
303
|
+
let count = 0;
|
|
304
|
+
// Remove terminal entries older than retention period
|
|
305
|
+
for (const [key, entry] of this.entries) {
|
|
306
|
+
if (isTerminalStatus(entry.status)) {
|
|
307
|
+
const updatedTime = new Date(entry.updatedAt).getTime();
|
|
308
|
+
if (updatedTime < cutoff) {
|
|
309
|
+
this.entries.delete(key);
|
|
310
|
+
count++;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Enforce MAX_ENTRIES: prune oldest terminal entries first
|
|
315
|
+
if (this.entries.size > THREAD_STATUS_CONSTANTS.MAX_ENTRIES) {
|
|
316
|
+
const terminalEntries = Array.from(this.entries.entries())
|
|
317
|
+
.filter(([, e]) => isTerminalStatus(e.status))
|
|
318
|
+
.sort(([, a], [, b]) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
|
319
|
+
for (const [key] of terminalEntries) {
|
|
320
|
+
if (this.entries.size <= THREAD_STATUS_CONSTANTS.MAX_ENTRIES)
|
|
321
|
+
break;
|
|
322
|
+
this.entries.delete(key);
|
|
323
|
+
count++;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (count > 0) {
|
|
327
|
+
this.lastCleanupAt = new Date().toISOString();
|
|
328
|
+
this.schedulePersist();
|
|
329
|
+
this.logger.info('Cleaned up entries', { removed: count, remaining: this.entries.size });
|
|
330
|
+
}
|
|
331
|
+
return count;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Returns statistics for monitoring the thread status queue.
|
|
335
|
+
*
|
|
336
|
+
* @returns Stats snapshot including total, per-status counts, and oldest pending timestamp
|
|
337
|
+
*/
|
|
338
|
+
getStats() {
|
|
339
|
+
const byStatus = {
|
|
340
|
+
enqueued: 0,
|
|
341
|
+
delivered: 0,
|
|
342
|
+
replied_completed: 0,
|
|
343
|
+
replied_waiting_actions: 0,
|
|
344
|
+
replied_to_follow_up: 0,
|
|
345
|
+
expired: 0,
|
|
346
|
+
error: 0,
|
|
347
|
+
};
|
|
348
|
+
let oldestPending = null;
|
|
349
|
+
for (const entry of this.entries.values()) {
|
|
350
|
+
byStatus[entry.status]++;
|
|
351
|
+
if (!isTerminalStatus(entry.status)) {
|
|
352
|
+
if (oldestPending === null || entry.receivedAt < oldestPending) {
|
|
353
|
+
oldestPending = entry.receivedAt;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
total: this.entries.size,
|
|
359
|
+
byStatus,
|
|
360
|
+
oldestPending,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Initializes the service with a crewly home directory for persistence.
|
|
365
|
+
* Must be called before loadPersistedState() when using getInstance().
|
|
366
|
+
*
|
|
367
|
+
* @param crewlyHome - Absolute path to the crewly home directory (e.g. ~/.crewly)
|
|
368
|
+
*/
|
|
369
|
+
init(crewlyHome) {
|
|
370
|
+
this.persistPath = path.join(crewlyHome, THREAD_STATUS_CONSTANTS.STORAGE_FILE);
|
|
371
|
+
this.logger.info('Initialized', { persistPath: this.persistPath });
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Public convenience method: optionally re-inits the persist path,
|
|
375
|
+
* then loads persisted state from disk.
|
|
376
|
+
*
|
|
377
|
+
* @param crewlyHome - Optional crewly home directory to re-initialize with
|
|
378
|
+
*/
|
|
379
|
+
async load(crewlyHome) {
|
|
380
|
+
if (crewlyHome) {
|
|
381
|
+
this.init(crewlyHome);
|
|
382
|
+
}
|
|
383
|
+
await this.loadPersistedState();
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Immediately flushes the current state to disk. Call during graceful shutdown
|
|
387
|
+
* to ensure no in-memory mutations are lost.
|
|
388
|
+
*/
|
|
389
|
+
async persist() {
|
|
390
|
+
// Cancel any debounced write — we flush synchronously
|
|
391
|
+
if (this.persistTimer) {
|
|
392
|
+
clearTimeout(this.persistTimer);
|
|
393
|
+
this.persistTimer = null;
|
|
394
|
+
}
|
|
395
|
+
await this.persistToDisk();
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Cleans up timers. Call on shutdown.
|
|
399
|
+
*/
|
|
400
|
+
destroy() {
|
|
401
|
+
if (this.persistTimer) {
|
|
402
|
+
clearTimeout(this.persistTimer);
|
|
403
|
+
this.persistTimer = null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// ===========================================================================
|
|
407
|
+
// Private Helpers
|
|
408
|
+
// ===========================================================================
|
|
409
|
+
/**
|
|
410
|
+
* Retrieves an entry by threadKey or throws an error with a descriptive message.
|
|
411
|
+
*
|
|
412
|
+
* @param threadKey - Platform-specific thread identifier
|
|
413
|
+
* @param caller - Name of the calling method (for error messages)
|
|
414
|
+
* @returns The found entry
|
|
415
|
+
* @throws Error if entry is not found
|
|
416
|
+
*/
|
|
417
|
+
getOrThrow(threadKey, caller) {
|
|
418
|
+
const entry = this.entries.get(threadKey);
|
|
419
|
+
if (!entry) {
|
|
420
|
+
throw new Error(`${caller}: thread not found — threadKey=${threadKey}`);
|
|
421
|
+
}
|
|
422
|
+
return entry;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Schedules a debounced persistence write to disk.
|
|
426
|
+
* Multiple mutations within PERSIST_DEBOUNCE_MS are batched into a single write.
|
|
427
|
+
*/
|
|
428
|
+
schedulePersist() {
|
|
429
|
+
if (!this.persistPath)
|
|
430
|
+
return;
|
|
431
|
+
if (this.persistTimer) {
|
|
432
|
+
clearTimeout(this.persistTimer);
|
|
433
|
+
}
|
|
434
|
+
this.persistTimer = setTimeout(() => {
|
|
435
|
+
this.persistTimer = null;
|
|
436
|
+
this.persistToDisk().catch((err) => {
|
|
437
|
+
this.logger.warn('Failed to persist thread status queue', { error: String(err) });
|
|
438
|
+
});
|
|
439
|
+
}, THREAD_STATUS_CONSTANTS.PERSIST_DEBOUNCE_MS);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Writes the current queue state to disk atomically.
|
|
443
|
+
*/
|
|
444
|
+
async persistToDisk() {
|
|
445
|
+
if (!this.persistPath) {
|
|
446
|
+
this.logger.warn('persistToDisk called before init — skipping');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const state = {
|
|
450
|
+
version: PERSISTED_THREAD_STATUS_VERSION,
|
|
451
|
+
entries: Array.from(this.entries.values()),
|
|
452
|
+
lastCleanupAt: this.lastCleanupAt,
|
|
453
|
+
};
|
|
454
|
+
await atomicWriteFile(this.persistPath, JSON.stringify(state, null, 2));
|
|
455
|
+
this.logger.debug('Persisted to disk', { entryCount: state.entries.length });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
//# sourceMappingURL=thread-status-queue.service.js.map
|