crewly 1.20.182 → 1.20.184
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/config/roles/orchestrator/prompt.md +4 -0
- package/config/skills/agent/core/report-status/execute.sh +4 -2
- package/dist/backend/backend/src/constants.d.ts +79 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +79 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js +22 -1
- package/dist/backend/backend/src/controllers/chat/agent-reply.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.js +25 -24
- package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/request/open-items.controller.d.ts +25 -0
- package/dist/backend/backend/src/controllers/request/open-items.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/request/open-items.controller.js +68 -0
- package/dist/backend/backend/src/controllers/request/open-items.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/request/request.routes.d.ts +2 -0
- package/dist/backend/backend/src/controllers/request/request.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/request/request.routes.js +6 -0
- package/dist/backend/backend/src/controllers/request/request.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.js +15 -0
- package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +57 -1
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/scripts/open-items-backfill.d.ts +16 -0
- package/dist/backend/backend/src/scripts/open-items-backfill.d.ts.map +1 -0
- package/dist/backend/backend/src/scripts/open-items-backfill.js +112 -0
- package/dist/backend/backend/src/scripts/open-items-backfill.js.map +1 -0
- package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/agent-registration.service.js +4 -2
- package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +9 -0
- 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 +11 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.d.ts +10 -0
- 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 +20 -0
- package/dist/backend/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts +8 -1
- package/dist/backend/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/decisions/decision.service.js +12 -2
- package/dist/backend/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/message-queue.service.d.ts +2 -1
- package/dist/backend/backend/src/services/messaging/message-queue.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/message-queue.service.js +6 -3
- package/dist/backend/backend/src/services/messaging/message-queue.service.js.map +1 -1
- 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 +6 -1
- package/dist/backend/backend/src/services/messaging/queue-processor.service.js.map +1 -1
- package/dist/backend/backend/src/services/open-items/open-item-card.d.ts +66 -0
- package/dist/backend/backend/src/services/open-items/open-item-card.d.ts.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-item-card.js +208 -0
- package/dist/backend/backend/src/services/open-items/open-item-card.js.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts +114 -0
- package/dist/backend/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-item-extractor.js +394 -0
- package/dist/backend/backend/src/services/open-items/open-item-extractor.js.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-items-backfill.d.ts +70 -0
- package/dist/backend/backend/src/services/open-items/open-items-backfill.d.ts.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-items-backfill.js +147 -0
- package/dist/backend/backend/src/services/open-items/open-items-backfill.js.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts +332 -0
- package/dist/backend/backend/src/services/open-items/open-items.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-items.service.js +785 -0
- package/dist/backend/backend/src/services/open-items/open-items.service.js.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts +44 -0
- package/dist/backend/backend/src/services/open-items/open-items.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/open-items/open-items.wiring.js +172 -0
- package/dist/backend/backend/src/services/open-items/open-items.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-delivery-enforcer.service.d.ts +3 -1
- package/dist/backend/backend/src/services/orc/orc-delivery-enforcer.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/orc-delivery-enforcer.service.js +7 -4
- package/dist/backend/backend/src/services/orc/orc-delivery-enforcer.service.js.map +1 -1
- package/dist/backend/backend/src/services/orc/orc-status-router.service.d.ts +110 -0
- package/dist/backend/backend/src/services/orc/orc-status-router.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-status-router.service.js +229 -0
- package/dist/backend/backend/src/services/orc/orc-status-router.service.js.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-status-routing.d.ts +108 -0
- package/dist/backend/backend/src/services/orc/orc-status-routing.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-status-routing.js +145 -0
- package/dist/backend/backend/src/services/orc/orc-status-routing.js.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-wake-counter.d.ts +86 -0
- package/dist/backend/backend/src/services/orc/orc-wake-counter.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-wake-counter.js +130 -0
- package/dist/backend/backend/src/services/orc/orc-wake-counter.js.map +1 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.d.ts +33 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/work-item-destination.js +49 -0
- package/dist/backend/backend/src/services/orc/work-item-destination.js.map +1 -1
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts +2 -1
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js +10 -4
- package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +4 -0
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +16 -0
- 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 +31 -8
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/task-pool/give-up/give-up-recovery.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/give-up/give-up-recovery.service.js +2 -0
- package/dist/backend/backend/src/services/task-pool/give-up/give-up-recovery.service.js.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts +23 -0
- package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js +61 -0
- package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/request.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/request.service.js +15 -1
- package/dist/backend/backend/src/services/v3/request.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-review.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-review.service.js +5 -1
- package/dist/backend/backend/src/services/v3/ticket-review.service.js.map +1 -1
- package/dist/backend/backend/src/types/decision.types.d.ts +15 -1
- package/dist/backend/backend/src/types/decision.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/index.d.ts +2 -0
- package/dist/backend/backend/src/types/v2/index.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/index.js +1 -0
- package/dist/backend/backend/src/types/v2/index.js.map +1 -1
- package/dist/backend/backend/src/types/v2/open-item.types.d.ts +73 -0
- package/dist/backend/backend/src/types/v2/open-item.types.d.ts.map +1 -0
- package/dist/backend/backend/src/types/v2/open-item.types.js +14 -0
- package/dist/backend/backend/src/types/v2/open-item.types.js.map +1 -0
- package/dist/backend/backend/src/types/v2/request.types.d.ts +22 -1
- package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.js +10 -5
- package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +79 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +79 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/agent/agent-registration.service.js +4 -2
- package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +9 -0
- 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 +11 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.d.ts +10 -0
- 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 +20 -0
- package/dist/cli/backend/src/services/chat-v2/sqlite/message.store.js.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts +8 -1
- package/dist/cli/backend/src/services/decisions/decision.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/decisions/decision.service.js +12 -2
- package/dist/cli/backend/src/services/decisions/decision.service.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/message-queue.service.d.ts +2 -1
- package/dist/cli/backend/src/services/messaging/message-queue.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/messaging/message-queue.service.js +6 -3
- package/dist/cli/backend/src/services/messaging/message-queue.service.js.map +1 -1
- package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/messaging/queue-processor.service.js +6 -1
- package/dist/cli/backend/src/services/messaging/queue-processor.service.js.map +1 -1
- package/dist/cli/backend/src/services/open-items/open-item-card.d.ts +66 -0
- package/dist/cli/backend/src/services/open-items/open-item-card.d.ts.map +1 -0
- package/dist/cli/backend/src/services/open-items/open-item-card.js +208 -0
- package/dist/cli/backend/src/services/open-items/open-item-card.js.map +1 -0
- package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts +114 -0
- package/dist/cli/backend/src/services/open-items/open-item-extractor.d.ts.map +1 -0
- package/dist/cli/backend/src/services/open-items/open-item-extractor.js +394 -0
- package/dist/cli/backend/src/services/open-items/open-item-extractor.js.map +1 -0
- package/dist/cli/backend/src/services/orc/orc-delivery-enforcer.service.d.ts +3 -1
- package/dist/cli/backend/src/services/orc/orc-delivery-enforcer.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/orc/orc-delivery-enforcer.service.js +7 -4
- package/dist/cli/backend/src/services/orc/orc-delivery-enforcer.service.js.map +1 -1
- package/dist/cli/backend/src/services/orc/orc-status-routing.d.ts +108 -0
- package/dist/cli/backend/src/services/orc/orc-status-routing.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orc/orc-status-routing.js +145 -0
- package/dist/cli/backend/src/services/orc/orc-status-routing.js.map +1 -0
- package/dist/cli/backend/src/services/orc/orc-wake-counter.d.ts +86 -0
- package/dist/cli/backend/src/services/orc/orc-wake-counter.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orc/orc-wake-counter.js +130 -0
- package/dist/cli/backend/src/services/orc/orc-wake-counter.js.map +1 -0
- package/dist/cli/backend/src/services/orc/work-item-destination.d.ts +198 -0
- package/dist/cli/backend/src/services/orc/work-item-destination.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orc/work-item-destination.js +288 -0
- package/dist/cli/backend/src/services/orc/work-item-destination.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +16 -0
- 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 +31 -8
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts +23 -0
- package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js +61 -0
- package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
- package/dist/cli/backend/src/services/v3/request.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/request.service.js +15 -1
- package/dist/cli/backend/src/services/v3/request.service.js.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-review.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-review.service.js +5 -1
- package/dist/cli/backend/src/services/v3/ticket-review.service.js.map +1 -1
- package/dist/cli/backend/src/types/decision.types.d.ts +15 -1
- package/dist/cli/backend/src/types/decision.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/index.d.ts +2 -0
- package/dist/cli/backend/src/types/v2/index.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/index.js +1 -0
- package/dist/cli/backend/src/types/v2/index.js.map +1 -1
- package/dist/cli/backend/src/types/v2/open-item.types.d.ts +73 -0
- package/dist/cli/backend/src/types/v2/open-item.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/v2/open-item.types.js +14 -0
- package/dist/cli/backend/src/types/v2/open-item.types.js.map +1 -0
- package/dist/cli/backend/src/types/v2/request.types.d.ts +22 -1
- package/dist/cli/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/request.types.js +10 -5
- package/dist/cli/backend/src/types/v2/request.types.js.map +1 -1
- package/frontend/dist/assets/{index-9c91de50.js → index-a6cb8533.js} +265 -265
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open items — Crewly tracks what agents promise and ask the owner in their
|
|
3
|
+
* replies, so nothing is silently dropped (specs/2026-10-01-reply-open-items.md).
|
|
4
|
+
*
|
|
5
|
+
* Incident (TKT-185, 2026-10-01): in #book-publish Atlas told the owner "Kai is
|
|
6
|
+
* going through the four episodes… 明天中午给我,我核过以后挑最有用的几条发你"
|
|
7
|
+
* and asked "第 13 章「互评当体检用」这个读法,你同意吗?". The ticket was already
|
|
8
|
+
* closed. Kai finished at 18:14 and Atlas verified it, but nobody delivered
|
|
9
|
+
* it and Atlas went idle; the question was never tracked.
|
|
10
|
+
*
|
|
11
|
+
* Now, for every agent message in a ticket's conversation:
|
|
12
|
+
*
|
|
13
|
+
* - **Extract** commitments and owner questions (rules, {@link extractOpenItems}).
|
|
14
|
+
* - **Hold the ticket open**: items live on `Request.openItems`; while one is
|
|
15
|
+
* active the ticket is `awaiting_followup` (RequestService gates `done`).
|
|
16
|
+
* - **Commitments**: a follow-up WorkItem for the agent, due at the parsed
|
|
17
|
+
* time; the delegated child work is linked; when that work is finished
|
|
18
|
+
* before the agent delivers, the agent is woken at once ("the work you
|
|
19
|
+
* promised the owner is ready — deliver it now"); past the due time the
|
|
20
|
+
* agent is nudged once, then the owner is told in the thread what is late
|
|
21
|
+
* and why; the agent's next post in the thread once the work is ready
|
|
22
|
+
* marks it delivered.
|
|
23
|
+
* - **Questions**: a decision card in the same thread, asked as the agent,
|
|
24
|
+
* with options and a default derived from its words ({@link deriveQuestionCard});
|
|
25
|
+
* answers reach the agent through the decision flow. No second card when
|
|
26
|
+
* the agent already asked the same thing with ask-owner.
|
|
27
|
+
*
|
|
28
|
+
* Collaborators are injected ({@link OpenItemsDeps}); `open-items.wiring.ts`
|
|
29
|
+
* connects the real ones.
|
|
30
|
+
*
|
|
31
|
+
* @module services/open-items/open-items.service
|
|
32
|
+
*/
|
|
33
|
+
import { OPEN_ITEMS_CONSTANTS } from '../../constants.js';
|
|
34
|
+
import { isInterim } from '../slack/slack-typing-placeholder.service.js';
|
|
35
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
36
|
+
import { ACTIVE_OPEN_ITEM_STATUSES } from '../../types/v2/open-item.types.js';
|
|
37
|
+
import { formatTicketNumber } from '../../types/v2/ticket.types.js';
|
|
38
|
+
import { extractOpenItems } from './open-item-extractor.js';
|
|
39
|
+
import { deriveQuestionCard, questionSimilarity } from './open-item-card.js';
|
|
40
|
+
import { formatWhen } from '../decisions/decision-card.js';
|
|
41
|
+
/** Statuses of a child WorkItem that count as finished work. */
|
|
42
|
+
const CHILD_DONE = new Set(['done', 'verified']);
|
|
43
|
+
/** Ticket statuses the service follows (`cancelled` closes items instead). */
|
|
44
|
+
const FOLLOWED = new Set(['open', 'ready', 'running', 'blocked', 'waiting_confirmation', 'awaiting_followup', 'done']);
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Pure helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
/**
|
|
49
|
+
* The Slack thread of a request's conversation (`origin.threadRef` is
|
|
50
|
+
* `slack:<channel>:<ts>`).
|
|
51
|
+
*
|
|
52
|
+
* @param request - Request
|
|
53
|
+
* @returns Place, or null for chat-only conversations
|
|
54
|
+
*/
|
|
55
|
+
export function slackPlaceOf(request) {
|
|
56
|
+
const ref = request.origin?.threadRef;
|
|
57
|
+
if (typeof ref !== 'string')
|
|
58
|
+
return null;
|
|
59
|
+
const m = /^slack:([CDG][A-Z0-9]+):(\d{6,}\.\d+)$/.exec(ref);
|
|
60
|
+
return m ? { slackChannelId: m[1], threadTs: m[2] } : null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether a message is in the request's conversation.
|
|
64
|
+
*
|
|
65
|
+
* @param request - Ticket with a chatRef
|
|
66
|
+
* @param message - chat-v2 message
|
|
67
|
+
* @returns True for its thread (or its root)
|
|
68
|
+
*/
|
|
69
|
+
export function inRequestThread(request, message) {
|
|
70
|
+
const ref = request.chatRef;
|
|
71
|
+
if (!ref || ref.channelId !== message.channelId)
|
|
72
|
+
return false;
|
|
73
|
+
const root = message.threadId ?? message.id;
|
|
74
|
+
return root === ref.threadRootId || root === ref.messageId;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether a WorkItem is bookkeeping (verify / follow-up) rather than work.
|
|
78
|
+
*
|
|
79
|
+
* @param wi - WorkItem
|
|
80
|
+
* @returns True to ignore it as child work
|
|
81
|
+
*/
|
|
82
|
+
function isBookkeeping(wi) {
|
|
83
|
+
const meta = (wi.metadata ?? {});
|
|
84
|
+
return (wi.type === 'review' ||
|
|
85
|
+
wi.id.includes(':verify:') ||
|
|
86
|
+
typeof meta.verifyOf === 'string' ||
|
|
87
|
+
meta[OPEN_ITEMS_CONSTANTS.FOLLOW_UP_METADATA_KEY] !== undefined);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether a WorkItem was started for this request's conversation: linked to
|
|
91
|
+
* the request, or (1.20.183 origin chain) carrying its thread as its origin.
|
|
92
|
+
*
|
|
93
|
+
* @param wi - WorkItem
|
|
94
|
+
* @param request - Request
|
|
95
|
+
* @returns True when it belongs to the request
|
|
96
|
+
*/
|
|
97
|
+
function belongsTo(wi, request) {
|
|
98
|
+
if (wi.requestId === request.id || request.workItemIds.includes(wi.id))
|
|
99
|
+
return true;
|
|
100
|
+
const origin = wi.metadata?.origin;
|
|
101
|
+
if (!origin || origin.kind !== 'owner')
|
|
102
|
+
return false;
|
|
103
|
+
if (request.chatRef && origin.chatThreadId === request.chatRef.threadRootId)
|
|
104
|
+
return true;
|
|
105
|
+
const place = slackPlaceOf(request);
|
|
106
|
+
return !!place && origin.slackChannelId === place.slackChannelId && origin.threadTs === place.threadTs;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The delegated child work a commitment waits on: WorkItems of the request
|
|
110
|
+
* given to someone else, started around the promise and not already finished
|
|
111
|
+
* before it.
|
|
112
|
+
*
|
|
113
|
+
* @param request - Request
|
|
114
|
+
* @param item - Commitment
|
|
115
|
+
* @param pool - Every WorkItem
|
|
116
|
+
* @returns Child WorkItem ids
|
|
117
|
+
*/
|
|
118
|
+
export function childWorkFor(request, item, pool) {
|
|
119
|
+
const at = Date.parse(item.createdAt);
|
|
120
|
+
const from = at - OPEN_ITEMS_CONSTANTS.CHILD_LOOKBACK_MS;
|
|
121
|
+
const to = at + OPEN_ITEMS_CONSTANTS.CHILD_LOOKAHEAD_MS;
|
|
122
|
+
const out = new Set();
|
|
123
|
+
for (const wi of pool) {
|
|
124
|
+
if (isBookkeeping(wi) || !belongsTo(wi, request))
|
|
125
|
+
continue;
|
|
126
|
+
if (!wi.target || wi.target === item.agent)
|
|
127
|
+
continue;
|
|
128
|
+
if (wi.status === 'cancelled')
|
|
129
|
+
continue;
|
|
130
|
+
const created = Date.parse(wi.createdAt);
|
|
131
|
+
if (!(created >= from && created <= to))
|
|
132
|
+
continue;
|
|
133
|
+
const finished = wi.completedAt ? Date.parse(wi.completedAt) : NaN;
|
|
134
|
+
if (CHILD_DONE.has(wi.status) && Number.isFinite(finished) && finished < at)
|
|
135
|
+
continue;
|
|
136
|
+
out.add(wi.id);
|
|
137
|
+
}
|
|
138
|
+
return [...out];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether every child of a commitment is finished (cancelled ones ignored).
|
|
142
|
+
*
|
|
143
|
+
* @param ids - Child ids
|
|
144
|
+
* @param pool - Every WorkItem
|
|
145
|
+
* @returns `ready` with the latest finish time, or not ready (with what is left)
|
|
146
|
+
*/
|
|
147
|
+
export function childrenState(ids, pool) {
|
|
148
|
+
const byId = new Map(pool.map((w) => [w.id, w]));
|
|
149
|
+
const pending = [];
|
|
150
|
+
const done = [];
|
|
151
|
+
let latest = 0;
|
|
152
|
+
for (const id of ids) {
|
|
153
|
+
const wi = byId.get(id);
|
|
154
|
+
if (!wi || wi.status === 'cancelled')
|
|
155
|
+
continue;
|
|
156
|
+
if (CHILD_DONE.has(wi.status)) {
|
|
157
|
+
done.push(wi);
|
|
158
|
+
latest = Math.max(latest, Date.parse(wi.completedAt ?? '') || 0);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
pending.push(wi);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const ready = done.length > 0 && pending.length === 0;
|
|
165
|
+
return { ready, ...(ready && latest > 0 ? { finishedAt: new Date(latest).toISOString() } : {}), pending, done };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether two items are the same thing said once: same message, or the same
|
|
169
|
+
* words by the same agent within {@link OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS}.
|
|
170
|
+
*
|
|
171
|
+
* @param a - Item
|
|
172
|
+
* @param b - Item
|
|
173
|
+
* @returns True for a duplicate
|
|
174
|
+
*/
|
|
175
|
+
export function isSameItem(a, b) {
|
|
176
|
+
if (a.text !== b.text)
|
|
177
|
+
return false;
|
|
178
|
+
if (a.sourceMessageId === b.sourceMessageId)
|
|
179
|
+
return true;
|
|
180
|
+
return a.agent === b.agent && Math.abs(Date.parse(a.createdAt) - Date.parse(b.createdAt)) <= OPEN_ITEMS_CONSTANTS.DUPLICATE_WINDOW_MS;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Clip for a one-line mention.
|
|
184
|
+
*
|
|
185
|
+
* @param s - Text
|
|
186
|
+
* @param max - Max characters
|
|
187
|
+
* @returns Clipped
|
|
188
|
+
*/
|
|
189
|
+
function short(s, max = 120) {
|
|
190
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
191
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* `--thread <channel>:<ts>` hint for the agent, when the conversation is in Slack.
|
|
195
|
+
*
|
|
196
|
+
* @param request - Request
|
|
197
|
+
* @returns Hint text (leading space) or empty
|
|
198
|
+
*/
|
|
199
|
+
function threadHint(request) {
|
|
200
|
+
const place = slackPlaceOf(request);
|
|
201
|
+
return place ? ` (--thread ${place.slackChannelId}:${place.threadTs})` : '';
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The ticket's display id.
|
|
205
|
+
*
|
|
206
|
+
* @param request - Request
|
|
207
|
+
* @returns `TKT-185`, else the id prefix
|
|
208
|
+
*/
|
|
209
|
+
function ticketLabel(request) {
|
|
210
|
+
return typeof request.ticketNumber === 'number' ? formatTicketNumber(request.ticketNumber) : request.id.slice(0, 8);
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Service
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
/**
|
|
216
|
+
* Tracks commitments and questions on Requests. See module docs.
|
|
217
|
+
*/
|
|
218
|
+
export class OpenItemsService {
|
|
219
|
+
deps;
|
|
220
|
+
static instance = null;
|
|
221
|
+
logger;
|
|
222
|
+
now;
|
|
223
|
+
chain = Promise.resolve();
|
|
224
|
+
timer = null;
|
|
225
|
+
/**
|
|
226
|
+
* @param deps - Collaborators
|
|
227
|
+
*/
|
|
228
|
+
constructor(deps) {
|
|
229
|
+
this.deps = deps;
|
|
230
|
+
this.logger = deps.logger ?? LoggerService.getInstance().createComponentLogger('OpenItems');
|
|
231
|
+
this.now = deps.now ?? (() => new Date());
|
|
232
|
+
}
|
|
233
|
+
/** @returns The process-wide instance, or null before wiring */
|
|
234
|
+
static getInstance() {
|
|
235
|
+
return OpenItemsService.instance;
|
|
236
|
+
}
|
|
237
|
+
/** @param service - Instance to install (null clears) */
|
|
238
|
+
static setInstance(service) {
|
|
239
|
+
OpenItemsService.instance = service;
|
|
240
|
+
}
|
|
241
|
+
/** Start the periodic sweep. */
|
|
242
|
+
start() {
|
|
243
|
+
if (this.timer)
|
|
244
|
+
return;
|
|
245
|
+
this.timer = setInterval(() => {
|
|
246
|
+
void this.sweep().catch((err) => this.logger.warn('Open-items sweep failed', { error: errText(err) }));
|
|
247
|
+
}, OPEN_ITEMS_CONSTANTS.SWEEP_INTERVAL_MS);
|
|
248
|
+
this.timer.unref?.();
|
|
249
|
+
}
|
|
250
|
+
/** Stop the sweep. */
|
|
251
|
+
stop() {
|
|
252
|
+
if (this.timer)
|
|
253
|
+
clearInterval(this.timer);
|
|
254
|
+
this.timer = null;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* One change at a time (a chat message, a WorkItem event and the sweep may
|
|
258
|
+
* touch the same request).
|
|
259
|
+
*/
|
|
260
|
+
serial(fn) {
|
|
261
|
+
const run = this.chain.then(fn);
|
|
262
|
+
this.chain = run.catch(() => undefined);
|
|
263
|
+
return run;
|
|
264
|
+
}
|
|
265
|
+
// -------------------------------------------------------------------------
|
|
266
|
+
// Agent replies
|
|
267
|
+
// -------------------------------------------------------------------------
|
|
268
|
+
/**
|
|
269
|
+
* The ticket an agent message belongs to: the one whose thread it is in
|
|
270
|
+
* (any status but cancelled, closed within the lookback), else — for a
|
|
271
|
+
* top-level message — the newest open ticket in that channel the agent is
|
|
272
|
+
* answering.
|
|
273
|
+
*
|
|
274
|
+
* @param message - chat-v2 message
|
|
275
|
+
* @param all - Every request
|
|
276
|
+
* @returns The request, or null
|
|
277
|
+
*/
|
|
278
|
+
findRequestFor(message, all) {
|
|
279
|
+
const since = this.now().getTime() - OPEN_ITEMS_CONSTANTS.LOOKBACK_MS;
|
|
280
|
+
const at = message.createdAt ?? this.now().getTime();
|
|
281
|
+
const tickets = all
|
|
282
|
+
.filter((r) => typeof r.ticketNumber === 'number' && !!r.chatRef && FOLLOWED.has(r.status) && Date.parse(r.updatedAt) >= since)
|
|
283
|
+
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
284
|
+
// Several tickets can share one thread (follow-up asks): the message
|
|
285
|
+
// belongs to the newest one opened before it — the open one first.
|
|
286
|
+
const sharing = tickets.filter((r) => inRequestThread(r, message));
|
|
287
|
+
const openedBefore = sharing.filter((r) => Date.parse(r.createdAt) <= at);
|
|
288
|
+
const inThread = openedBefore.length > 0 ? openedBefore : sharing;
|
|
289
|
+
if (inThread.length > 0)
|
|
290
|
+
return inThread.find((r) => r.status !== 'done') ?? inThread[0];
|
|
291
|
+
if (message.threadId)
|
|
292
|
+
return null;
|
|
293
|
+
return (tickets.find((r) => r.chatRef?.channelId === message.channelId &&
|
|
294
|
+
r.status !== 'done' &&
|
|
295
|
+
(r.assignee === message.senderId || r.reply?.by === message.senderId)) ?? null);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* An agent posted in chat-v2: mark what it delivered, and track what it
|
|
299
|
+
* newly promised or asked.
|
|
300
|
+
*
|
|
301
|
+
* @param message - The message
|
|
302
|
+
* @returns The request it changed, or null
|
|
303
|
+
*/
|
|
304
|
+
async onAgentMessage(message) {
|
|
305
|
+
if (message.senderType !== 'agent' || !message.content?.trim())
|
|
306
|
+
return null;
|
|
307
|
+
// "Got it — on it" placeholders are not the reply.
|
|
308
|
+
if (isInterim(message))
|
|
309
|
+
return null;
|
|
310
|
+
return this.serial(async () => {
|
|
311
|
+
const all = await this.deps.requests.listAll();
|
|
312
|
+
const request = this.findRequestFor(message, all);
|
|
313
|
+
if (!request)
|
|
314
|
+
return null;
|
|
315
|
+
const at = new Date(message.createdAt ?? this.now().getTime());
|
|
316
|
+
const pool = await this.deps.listWorkItems().catch(() => []);
|
|
317
|
+
const planned = await this.plan(request, message, at);
|
|
318
|
+
let items = [...(request.openItems ?? [])];
|
|
319
|
+
let changed = false;
|
|
320
|
+
// Deliveries first: this post may be what an earlier promise was waiting for.
|
|
321
|
+
const delivered = this.deliveries(items, message, at, pool);
|
|
322
|
+
for (const d of delivered) {
|
|
323
|
+
items = items.map((i) => (i.id === d.id ? d : i));
|
|
324
|
+
changed = true;
|
|
325
|
+
if (d.workItemId)
|
|
326
|
+
await this.closeFollowUp(d.workItemId, 'delivered', `Delivered to the owner (message ${message.id})`);
|
|
327
|
+
this.logger.info('Commitment delivered', { tkt: ticketLabel(request), item: d.id, by: message.senderId });
|
|
328
|
+
}
|
|
329
|
+
// The same words twice (a reply recorded both from the reply path and the
|
|
330
|
+
// Slack mirror) are one item.
|
|
331
|
+
const fresh = planned.filter((p) => !items.some((i) => isSameItem(i, p.item)));
|
|
332
|
+
for (const p of fresh) {
|
|
333
|
+
const item = await this.activate(request, p, pool);
|
|
334
|
+
items.push(item);
|
|
335
|
+
changed = true;
|
|
336
|
+
}
|
|
337
|
+
if (!changed)
|
|
338
|
+
return null;
|
|
339
|
+
return this.save(request, items, fresh.length > 0);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* What a message would add, without side effects (also the backfill's view).
|
|
344
|
+
*
|
|
345
|
+
* @param request - Its request
|
|
346
|
+
* @param message - The agent message
|
|
347
|
+
* @param at - When it was posted
|
|
348
|
+
* @returns Planned items
|
|
349
|
+
*/
|
|
350
|
+
async plan(request, message, at) {
|
|
351
|
+
const names = await this.deps.colleagueNames?.(message.senderId).catch(() => []);
|
|
352
|
+
const owner = this.deps.ownerSlackUserId?.() ?? (request.origin?.author?.startsWith('U') ? request.origin.author : null);
|
|
353
|
+
const found = extractOpenItems(message.content, {
|
|
354
|
+
now: at,
|
|
355
|
+
...(names ? { colleagueNames: names } : {}),
|
|
356
|
+
...(owner ? { ownerSlackUserId: owner } : {}),
|
|
357
|
+
});
|
|
358
|
+
const base = (type, n) => ({
|
|
359
|
+
id: `${type === 'commitment' ? 'c' : 'q'}-${message.id.slice(0, 8)}-${n}`,
|
|
360
|
+
agent: message.senderId,
|
|
361
|
+
sourceMessageId: message.id,
|
|
362
|
+
createdAt: at.toISOString(),
|
|
363
|
+
status: 'open',
|
|
364
|
+
});
|
|
365
|
+
const out = [];
|
|
366
|
+
found.commitments.forEach((c, i) => {
|
|
367
|
+
out.push({ item: { ...base('commitment', i + 1), type: 'commitment', text: c.text, due: c.due.toISOString(), dueSource: c.dueSource } });
|
|
368
|
+
});
|
|
369
|
+
let qi = 0;
|
|
370
|
+
for (const q of found.questions) {
|
|
371
|
+
qi += 1;
|
|
372
|
+
const item = { ...base('question', qi), type: 'question', text: q.text };
|
|
373
|
+
const asked = await this.alreadyAsked(message.senderId, q, at);
|
|
374
|
+
out.push(asked ? { item, linkedDecisionId: asked.id } : { item, card: deriveQuestionCard(q) });
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The ask-owner decision the agent already made for this question, if any.
|
|
380
|
+
*
|
|
381
|
+
* @param agent - Asking agent
|
|
382
|
+
* @param q - The question
|
|
383
|
+
* @param at - When it was asked in the reply
|
|
384
|
+
* @returns The decision, or null
|
|
385
|
+
*/
|
|
386
|
+
async alreadyAsked(agent, q, at) {
|
|
387
|
+
if (!this.deps.recentDecisionsBy)
|
|
388
|
+
return null;
|
|
389
|
+
const win = OPEN_ITEMS_CONSTANTS.ASK_OWNER_DEDUPE_WINDOW_MS;
|
|
390
|
+
const recent = await this.deps.recentDecisionsBy(agent, at.getTime() - win).catch(() => []);
|
|
391
|
+
return (recent.find((d) => d.kind !== 'reply_question' &&
|
|
392
|
+
Math.abs(Date.parse(d.createdAt) - at.getTime()) <= win &&
|
|
393
|
+
questionSimilarity(d.question, q.text) >= OPEN_ITEMS_CONSTANTS.SAME_QUESTION_SIMILARITY) ?? null);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Turn a planned item into a tracked one: link child work and queue the
|
|
397
|
+
* follow-up (commitment), or post / link the card (question).
|
|
398
|
+
*
|
|
399
|
+
* @param request - Its request
|
|
400
|
+
* @param p - Planned item
|
|
401
|
+
* @param pool - Every WorkItem
|
|
402
|
+
* @returns The stored item
|
|
403
|
+
*/
|
|
404
|
+
async activate(request, p, pool) {
|
|
405
|
+
const place = slackPlaceOf(request);
|
|
406
|
+
let item = { ...p.item };
|
|
407
|
+
if (item.type === 'commitment') {
|
|
408
|
+
const children = childWorkFor(request, item, pool);
|
|
409
|
+
if (children.length > 0)
|
|
410
|
+
item.childWorkItemIds = children;
|
|
411
|
+
const wiId = await this.deps.createFollowUp?.({ request, item, place }).catch((err) => {
|
|
412
|
+
this.logger.warn('Follow-up WorkItem not created', { tkt: ticketLabel(request), error: errText(err) });
|
|
413
|
+
return null;
|
|
414
|
+
});
|
|
415
|
+
if (wiId)
|
|
416
|
+
item.workItemId = wiId;
|
|
417
|
+
this.logger.info('Commitment tracked', { tkt: ticketLabel(request), item: item.id, agent: item.agent, due: item.due, children: children.length });
|
|
418
|
+
// The child work may already be finished (the promise came late).
|
|
419
|
+
const state = childrenState(item.childWorkItemIds ?? [], pool);
|
|
420
|
+
if (state.ready)
|
|
421
|
+
item = await this.markReady(request, item, state.finishedAt, state.done);
|
|
422
|
+
return item;
|
|
423
|
+
}
|
|
424
|
+
if (p.linkedDecisionId) {
|
|
425
|
+
item.decisionId = p.linkedDecisionId;
|
|
426
|
+
this.logger.info('Question already asked with ask-owner — no second card', { tkt: ticketLabel(request), decisionId: p.linkedDecisionId });
|
|
427
|
+
return item;
|
|
428
|
+
}
|
|
429
|
+
if (p.card && this.deps.askQuestion) {
|
|
430
|
+
const d = await this.deps.askQuestion({ request, item, card: p.card, place }).catch((err) => {
|
|
431
|
+
this.logger.warn('Question card not posted', { tkt: ticketLabel(request), error: errText(err) });
|
|
432
|
+
return null;
|
|
433
|
+
});
|
|
434
|
+
if (d)
|
|
435
|
+
item.decisionId = d.id;
|
|
436
|
+
}
|
|
437
|
+
this.logger.info('Question tracked', { tkt: ticketLabel(request), item: item.id, agent: item.agent, decisionId: item.decisionId });
|
|
438
|
+
return item;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Commitments this message delivers.
|
|
442
|
+
*
|
|
443
|
+
* @param items - Current items
|
|
444
|
+
* @param message - The post
|
|
445
|
+
* @param at - Its time
|
|
446
|
+
* @param pool - Every WorkItem
|
|
447
|
+
* @returns Updated (delivered) items
|
|
448
|
+
*/
|
|
449
|
+
deliveries(items, message, at, pool) {
|
|
450
|
+
const out = [];
|
|
451
|
+
for (const item of items) {
|
|
452
|
+
if (item.type !== 'commitment' || !ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
|
|
453
|
+
continue;
|
|
454
|
+
if (!this.deliveredBy(item, { ...message, createdAt: at.getTime() }, pool))
|
|
455
|
+
continue;
|
|
456
|
+
out.push({ ...item, status: 'delivered', closedAt: at.toISOString(), closedReason: `posted in the thread (message ${message.id})` });
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Whether a post in the thread delivers a commitment: by the promising
|
|
462
|
+
* agent (or one doing the child work), once the child work is finished.
|
|
463
|
+
* With no child work: a later post by the promising agent, at least
|
|
464
|
+
* {@link OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS} after the promise, that
|
|
465
|
+
* does not itself promise something new.
|
|
466
|
+
*
|
|
467
|
+
* @param item - Commitment
|
|
468
|
+
* @param message - The post (`createdAt` set)
|
|
469
|
+
* @param pool - Every WorkItem
|
|
470
|
+
* @returns True when it is the delivery
|
|
471
|
+
*/
|
|
472
|
+
deliveredBy(item, message, pool) {
|
|
473
|
+
if (item.type !== 'commitment' || item.sourceMessageId === message.id)
|
|
474
|
+
return false;
|
|
475
|
+
if (message.senderType !== 'agent' || isInterim(message))
|
|
476
|
+
return false;
|
|
477
|
+
const at = message.createdAt ?? this.now().getTime();
|
|
478
|
+
if (at <= Date.parse(item.createdAt))
|
|
479
|
+
return false;
|
|
480
|
+
const children = item.childWorkItemIds ?? [];
|
|
481
|
+
if (children.length > 0) {
|
|
482
|
+
const childTargets = new Set(pool.filter((w) => children.includes(w.id)).map((w) => w.target));
|
|
483
|
+
if (message.senderId !== item.agent && !childTargets.has(message.senderId))
|
|
484
|
+
return false;
|
|
485
|
+
const state = childrenState(children, pool);
|
|
486
|
+
if (!state.ready)
|
|
487
|
+
return false;
|
|
488
|
+
// Posted after the work was finished.
|
|
489
|
+
return !state.finishedAt || at >= Date.parse(state.finishedAt);
|
|
490
|
+
}
|
|
491
|
+
if (message.senderId !== item.agent)
|
|
492
|
+
return false;
|
|
493
|
+
if (at - Date.parse(item.createdAt) < OPEN_ITEMS_CONSTANTS.MIN_DELIVERY_GAP_MS)
|
|
494
|
+
return false;
|
|
495
|
+
return extractOpenItems(message.content, { now: new Date(at) }).commitments.length === 0;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Take planned items onto a request (the backfill's apply step).
|
|
499
|
+
*
|
|
500
|
+
* @param requestId - Request
|
|
501
|
+
* @param planned - Items from {@link plan}
|
|
502
|
+
* @returns Updated request, or null when it is gone
|
|
503
|
+
*/
|
|
504
|
+
async adopt(requestId, planned) {
|
|
505
|
+
return this.serial(async () => {
|
|
506
|
+
const request = await this.deps.requests.getById(requestId);
|
|
507
|
+
if (!request)
|
|
508
|
+
return null;
|
|
509
|
+
const pool = await this.deps.listWorkItems().catch(() => []);
|
|
510
|
+
const items = [...(request.openItems ?? [])];
|
|
511
|
+
for (const p of planned) {
|
|
512
|
+
if (items.some((i) => i.id === p.item.id))
|
|
513
|
+
continue;
|
|
514
|
+
items.push(await this.activate(request, p, pool));
|
|
515
|
+
}
|
|
516
|
+
return this.save(request, items, true);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Store items, and move the request between `done` and `awaiting_followup`.
|
|
521
|
+
*
|
|
522
|
+
* @param request - Request as read
|
|
523
|
+
* @param items - New item list
|
|
524
|
+
* @param added - New active items were added
|
|
525
|
+
* @returns Updated request
|
|
526
|
+
*/
|
|
527
|
+
async save(request, items, added) {
|
|
528
|
+
const active = items.some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status));
|
|
529
|
+
if (active && request.status === 'done' && added) {
|
|
530
|
+
this.logger.info('Closed ticket reopened: its agent left open items in the thread', { tkt: ticketLabel(request) });
|
|
531
|
+
return this.deps.requests.update(request.id, { openItems: items, status: 'awaiting_followup', reopenForFollowup: true });
|
|
532
|
+
}
|
|
533
|
+
if (!active && request.status === 'awaiting_followup') {
|
|
534
|
+
this.logger.info('Every open item closed — ticket done', { tkt: ticketLabel(request) });
|
|
535
|
+
return this.deps.requests.update(request.id, { openItems: items, status: 'done', accepted: true, ignoreDeadChildren: true });
|
|
536
|
+
}
|
|
537
|
+
return this.deps.requests.update(request.id, { openItems: items });
|
|
538
|
+
}
|
|
539
|
+
// -------------------------------------------------------------------------
|
|
540
|
+
// Child work
|
|
541
|
+
// -------------------------------------------------------------------------
|
|
542
|
+
/**
|
|
543
|
+
* A WorkItem finished (task:done / task:verified): wake whoever promised
|
|
544
|
+
* the owner the result, if that was the last piece.
|
|
545
|
+
*
|
|
546
|
+
* @param workItemId - The WorkItem
|
|
547
|
+
* @returns Items that became ready
|
|
548
|
+
*/
|
|
549
|
+
async onWorkItemSettled(workItemId) {
|
|
550
|
+
return this.serial(async () => {
|
|
551
|
+
const woken = [];
|
|
552
|
+
const all = await this.deps.requests.listAll();
|
|
553
|
+
const affected = all.filter((r) => (r.openItems ?? []).some((i) => i.type === 'commitment' && i.status === 'open' && (i.childWorkItemIds ?? []).includes(workItemId)));
|
|
554
|
+
if (affected.length === 0)
|
|
555
|
+
return woken;
|
|
556
|
+
const pool = await this.deps.listWorkItems();
|
|
557
|
+
for (const request of affected) {
|
|
558
|
+
let changed = false;
|
|
559
|
+
const items = [];
|
|
560
|
+
for (const item of request.openItems ?? []) {
|
|
561
|
+
if (item.type === 'commitment' && item.status === 'open' && (item.childWorkItemIds ?? []).includes(workItemId)) {
|
|
562
|
+
const state = childrenState(item.childWorkItemIds ?? [], pool);
|
|
563
|
+
if (state.ready) {
|
|
564
|
+
const ready = await this.markReady(request, item, state.finishedAt, state.done);
|
|
565
|
+
items.push(ready);
|
|
566
|
+
woken.push(ready);
|
|
567
|
+
changed = true;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
items.push(item);
|
|
572
|
+
}
|
|
573
|
+
if (changed)
|
|
574
|
+
await this.deps.requests.update(request.id, { openItems: items });
|
|
575
|
+
}
|
|
576
|
+
return woken;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* The child work is finished: wake the agent to deliver.
|
|
581
|
+
*
|
|
582
|
+
* @param request - Request
|
|
583
|
+
* @param item - Commitment
|
|
584
|
+
* @param finishedAt - When the last child finished
|
|
585
|
+
* @param done - Finished children
|
|
586
|
+
* @returns Updated item (`ready`)
|
|
587
|
+
*/
|
|
588
|
+
async markReady(request, item, finishedAt, done) {
|
|
589
|
+
const now = this.now();
|
|
590
|
+
const what = done.map((w) => `"${short(w.title, 80)}"`).join(', ');
|
|
591
|
+
const text = `[FOLLOW-UP ${ticketLabel(request)}] The work you promised the owner is ready (${what}) — deliver it now. ` +
|
|
592
|
+
`You said: "${short(item.text, 200)}". Post it in the same thread${threadHint(request)}; that closes the follow-up.`;
|
|
593
|
+
const ok = await this.deps.deliverToAgent(item.agent, text).catch(() => false);
|
|
594
|
+
this.logger.info('Promised work is ready — agent woken to deliver', { tkt: ticketLabel(request), item: item.id, agent: item.agent, delivered: ok });
|
|
595
|
+
return { ...item, status: 'ready', readyAt: finishedAt ?? now.toISOString(), ...(ok ? { wokeAt: now.toISOString() } : {}) };
|
|
596
|
+
}
|
|
597
|
+
// -------------------------------------------------------------------------
|
|
598
|
+
// Decisions
|
|
599
|
+
// -------------------------------------------------------------------------
|
|
600
|
+
/**
|
|
601
|
+
* A `reply_question` decision settled: close its open item and give the
|
|
602
|
+
* agent the answer (the generic decision note).
|
|
603
|
+
*
|
|
604
|
+
* @param d - The decision
|
|
605
|
+
* @param fallback - The generic note
|
|
606
|
+
* @returns The note for the agent (null when withdrawn)
|
|
607
|
+
*/
|
|
608
|
+
async onDecisionSettled(d, fallback) {
|
|
609
|
+
const ref = d.requestRef;
|
|
610
|
+
if (!ref)
|
|
611
|
+
return fallback ?? null;
|
|
612
|
+
// Not awaited: the handler can run inside this service's own chain (the
|
|
613
|
+
// sweep withdrawing a card), and the note does not depend on the write.
|
|
614
|
+
void this.serial(async () => {
|
|
615
|
+
const request = await this.deps.requests.getById(ref.requestId);
|
|
616
|
+
if (!request)
|
|
617
|
+
return;
|
|
618
|
+
const now = this.now().toISOString();
|
|
619
|
+
const status = d.status === 'resolved' || d.status === 'defaulted' ? 'resolved' : d.status === 'cancelled' ? 'superseded' : d.status === 'expired' ? 'expired' : 'open';
|
|
620
|
+
if (status === 'open')
|
|
621
|
+
return; // parked: still waiting on the owner
|
|
622
|
+
const answer = d.chosenKey ? d.options.find((o) => o.key === d.chosenKey)?.label : d.answerText;
|
|
623
|
+
const items = (request.openItems ?? []).map((i) => i.id === ref.itemId && ACTIVE_OPEN_ITEM_STATUSES.has(i.status)
|
|
624
|
+
? { ...i, status, closedAt: now, closedReason: `${d.id} ${d.status}`, ...(answer ? { answer } : {}) }
|
|
625
|
+
: i);
|
|
626
|
+
await this.save(request, items, false);
|
|
627
|
+
}).catch((err) => this.logger.warn('Answered question not recorded', { decisionId: d.id, error: errText(err) }));
|
|
628
|
+
if (d.status === 'cancelled')
|
|
629
|
+
return null;
|
|
630
|
+
const reply = d.chosenKey && d.options.find((o) => o.key === d.chosenKey)?.label === OPEN_ITEMS_CONSTANTS.REPLY_LABEL;
|
|
631
|
+
if (reply) {
|
|
632
|
+
return `[DECISION ${d.id}] The owner will answer "${d.question}" in words in the thread. Wait for their message there, then act on it.`;
|
|
633
|
+
}
|
|
634
|
+
return fallback ?? null;
|
|
635
|
+
}
|
|
636
|
+
// -------------------------------------------------------------------------
|
|
637
|
+
// Sweep
|
|
638
|
+
// -------------------------------------------------------------------------
|
|
639
|
+
/**
|
|
640
|
+
* Periodic pass: link late child work, catch missed readiness, nudge
|
|
641
|
+
* overdue promises once and then tell the owner, sync answered questions,
|
|
642
|
+
* expire stale items, close cancelled tickets' items.
|
|
643
|
+
*
|
|
644
|
+
* @returns Counts of what happened
|
|
645
|
+
*/
|
|
646
|
+
async sweep() {
|
|
647
|
+
return this.serial(async () => {
|
|
648
|
+
const counts = { ready: 0, nudged: 0, ownerNotes: 0, expired: 0, closed: 0 };
|
|
649
|
+
const now = this.now();
|
|
650
|
+
const all = await this.deps.requests.listAll();
|
|
651
|
+
const tracked = all.filter((r) => r.status === 'awaiting_followup' || (r.openItems ?? []).some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status)));
|
|
652
|
+
if (tracked.length === 0)
|
|
653
|
+
return counts;
|
|
654
|
+
const pool = await this.deps.listWorkItems().catch(() => []);
|
|
655
|
+
for (const request of tracked) {
|
|
656
|
+
try {
|
|
657
|
+
const before = JSON.stringify(request.openItems);
|
|
658
|
+
const items = [];
|
|
659
|
+
for (const item of request.openItems ?? [])
|
|
660
|
+
items.push(await this.sweepItem(request, item, now, pool, counts));
|
|
661
|
+
const changed = JSON.stringify(items) !== before;
|
|
662
|
+
const allClosed = !items.some((i) => ACTIVE_OPEN_ITEM_STATUSES.has(i.status));
|
|
663
|
+
if (changed || (request.status === 'awaiting_followup' && allClosed))
|
|
664
|
+
await this.save(request, items, false);
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
this.logger.warn('Open-items sweep step failed', { id: request.id, error: errText(err) });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (counts.ready + counts.nudged + counts.ownerNotes + counts.expired + counts.closed > 0) {
|
|
671
|
+
this.logger.info('Open-items sweep', counts);
|
|
672
|
+
}
|
|
673
|
+
return counts;
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* One item's sweep step.
|
|
678
|
+
*/
|
|
679
|
+
async sweepItem(request, item, now, pool, counts) {
|
|
680
|
+
if (!ACTIVE_OPEN_ITEM_STATUSES.has(item.status))
|
|
681
|
+
return item;
|
|
682
|
+
const nowIso = now.toISOString();
|
|
683
|
+
if (request.status === 'cancelled') {
|
|
684
|
+
counts.closed += 1;
|
|
685
|
+
if (item.workItemId)
|
|
686
|
+
await this.closeFollowUp(item.workItemId, 'cancelled', 'The ticket was cancelled');
|
|
687
|
+
if (item.decisionId)
|
|
688
|
+
await this.deps.cancelQuestion?.(item.decisionId, 'ticket cancelled').catch(() => undefined);
|
|
689
|
+
return { ...item, status: 'cancelled', closedAt: nowIso, closedReason: 'ticket cancelled' };
|
|
690
|
+
}
|
|
691
|
+
if (now.getTime() - Date.parse(item.createdAt) >= OPEN_ITEMS_CONSTANTS.EXPIRE_AFTER_MS) {
|
|
692
|
+
counts.expired += 1;
|
|
693
|
+
if (item.workItemId)
|
|
694
|
+
await this.closeFollowUp(item.workItemId, 'cancelled', 'Expired: nothing happened for 7 days');
|
|
695
|
+
return { ...item, status: 'expired', closedAt: nowIso, closedReason: 'no activity for 7 days' };
|
|
696
|
+
}
|
|
697
|
+
if (item.type === 'question') {
|
|
698
|
+
if (!item.decisionId || !this.deps.getDecision)
|
|
699
|
+
return item;
|
|
700
|
+
const d = await this.deps.getDecision(item.decisionId).catch(() => null);
|
|
701
|
+
if (!d || d.status === 'open' || d.status === 'parked')
|
|
702
|
+
return item;
|
|
703
|
+
const status = d.status === 'cancelled' ? 'superseded' : d.status === 'expired' ? 'expired' : 'resolved';
|
|
704
|
+
const answer = d.chosenKey ? d.options.find((o) => o.key === d.chosenKey)?.label : d.answerText;
|
|
705
|
+
counts.closed += 1;
|
|
706
|
+
return { ...item, status, closedAt: nowIso, closedReason: `${d.id} ${d.status}`, ...(answer ? { answer } : {}) };
|
|
707
|
+
}
|
|
708
|
+
// Commitment: link child work started after the promise.
|
|
709
|
+
let current = item;
|
|
710
|
+
const children = childWorkFor(request, item, pool);
|
|
711
|
+
if (children.some((id) => !(item.childWorkItemIds ?? []).includes(id))) {
|
|
712
|
+
current = { ...current, childWorkItemIds: [...new Set([...(item.childWorkItemIds ?? []), ...children])] };
|
|
713
|
+
}
|
|
714
|
+
if (current.status === 'open' && (current.childWorkItemIds ?? []).length > 0) {
|
|
715
|
+
const state = childrenState(current.childWorkItemIds ?? [], pool);
|
|
716
|
+
if (state.ready) {
|
|
717
|
+
counts.ready += 1;
|
|
718
|
+
return this.markReady(request, current, state.finishedAt, state.done);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const due = current.due ? Date.parse(current.due) : NaN;
|
|
722
|
+
if (!Number.isFinite(due) || now.getTime() < due)
|
|
723
|
+
return current;
|
|
724
|
+
if (!current.nudgedAt) {
|
|
725
|
+
const text = `[FOLLOW-UP ${ticketLabel(request)}] You promised the owner: "${short(current.text, 200)}" — due ${formatWhen(new Date(due), now)}, and it hasn't been delivered. ` +
|
|
726
|
+
`Deliver it now in the same thread${threadHint(request)}, or tell the owner plainly when it will come and why.`;
|
|
727
|
+
const ok = await this.deps.deliverToAgent(current.agent, text).catch(() => false);
|
|
728
|
+
counts.nudged += 1;
|
|
729
|
+
this.logger.info('Overdue promise — agent nudged', { tkt: ticketLabel(request), item: current.id, agent: current.agent, delivered: ok });
|
|
730
|
+
return { ...current, status: 'overdue', nudgedAt: nowIso };
|
|
731
|
+
}
|
|
732
|
+
if (!current.ownerNotifiedAt && now.getTime() - Date.parse(current.nudgedAt) >= OPEN_ITEMS_CONSTANTS.OWNER_NOTE_AFTER_NUDGE_MS) {
|
|
733
|
+
const note = await this.ownerNote(request, current, pool, now);
|
|
734
|
+
const ok = await this.deps.postOwnerNote(request, note).catch(() => false);
|
|
735
|
+
counts.ownerNotes += 1;
|
|
736
|
+
this.logger.info('Overdue promise — owner told in the thread', { tkt: ticketLabel(request), item: current.id, posted: ok });
|
|
737
|
+
return ok ? { ...current, status: 'overdue', ownerNotifiedAt: nowIso } : current;
|
|
738
|
+
}
|
|
739
|
+
return current;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* The owner's note for a late promise: what, by when, and why it is late.
|
|
743
|
+
*
|
|
744
|
+
* @param request - Request
|
|
745
|
+
* @param item - Commitment
|
|
746
|
+
* @param pool - Every WorkItem
|
|
747
|
+
* @param now - Clock
|
|
748
|
+
* @returns Note text (English harness text; the agent's words quoted)
|
|
749
|
+
*/
|
|
750
|
+
async ownerNote(request, item, pool, now) {
|
|
751
|
+
const name = (await this.deps.displayName?.(item.agent).catch(() => undefined)) ?? item.agent;
|
|
752
|
+
const due = item.due ? formatWhen(new Date(item.due), now) : 'today';
|
|
753
|
+
const state = childrenState(item.childWorkItemIds ?? [], pool);
|
|
754
|
+
let why;
|
|
755
|
+
if (state.pending.length > 0) {
|
|
756
|
+
const parts = [];
|
|
757
|
+
for (const w of state.pending.slice(0, 2)) {
|
|
758
|
+
const who = w.target ? ((await this.deps.displayName?.(w.target).catch(() => undefined)) ?? w.target) : 'nobody';
|
|
759
|
+
parts.push(`${who}'s part ("${short(w.title, 60)}") is still ${w.status.replace(/_/g, ' ')}`);
|
|
760
|
+
}
|
|
761
|
+
why = parts.join('; ');
|
|
762
|
+
}
|
|
763
|
+
else if (state.ready || item.readyAt) {
|
|
764
|
+
const at = item.readyAt ? formatWhen(new Date(item.readyAt), now) : 'earlier';
|
|
765
|
+
why = `the work was ready at ${at}, but ${name} hasn't posted it`;
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
why = `${name} hasn't posted it yet`;
|
|
769
|
+
}
|
|
770
|
+
return `${name} promised "${short(item.text, 160)}" by ${due}. It hasn't arrived: ${why}. ${name} has been reminded.`;
|
|
771
|
+
}
|
|
772
|
+
async closeFollowUp(workItemId, outcome, reason) {
|
|
773
|
+
await this.deps.closeFollowUp?.(workItemId, outcome, reason).catch((err) => this.logger.debug('Follow-up WorkItem not closed', { workItemId, error: errText(err) }));
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Message of an unknown error.
|
|
778
|
+
*
|
|
779
|
+
* @param err - Thrown value
|
|
780
|
+
* @returns Text
|
|
781
|
+
*/
|
|
782
|
+
function errText(err) {
|
|
783
|
+
return err instanceof Error ? err.message : String(err);
|
|
784
|
+
}
|
|
785
|
+
//# sourceMappingURL=open-items.service.js.map
|