@bastani/atomic 0.9.18-alpha.2 → 0.9.18-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin/intercom/CHANGELOG.md +8 -0
- package/dist/builtin/intercom/README.md +3 -3
- package/dist/builtin/intercom/broker/broker.ts +156 -12
- package/dist/builtin/intercom/broker/client.ts +89 -49
- package/dist/builtin/intercom/broker/pending-question-index.ts +10 -0
- package/dist/builtin/intercom/broker/send-handler.ts +32 -17
- package/dist/builtin/intercom/index.bundle.mjs +153 -50
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/intercom/skills/intercom/SKILL.md +1 -1
- package/dist/builtin/intercom/types.ts +30 -2
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +18 -0
- package/dist/builtin/workflows/README.md +11 -2
- package/dist/builtin/workflows/builtin/{chunk-ngffz3y8.js → chunk-fghhy2a5.js} +1 -1
- package/dist/builtin/workflows/builtin/{chunk-6v0yv8tj.js → chunk-h3r2vkzc.js} +1 -1
- package/dist/builtin/workflows/builtin/{chunk-brerg33r.js → chunk-n58a7v26.js} +0 -1
- package/dist/builtin/workflows/builtin/goal.js +2 -2
- package/dist/builtin/workflows/builtin/index.js +3 -3
- package/dist/builtin/workflows/builtin/ralph.js +2 -2
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/src/extension/index.bundle.mjs +258 -49
- package/dist/builtin/workflows/src/index.js +35 -9
- package/docs/intercom.md +7 -5
- package/docs/workflows.md +18 -9
- package/npm-shrinkwrap.json +32 -32
- package/package.json +3 -3
|
@@ -4,6 +4,14 @@ All notable changes to the `pi-intercom` extension will be documented in this fi
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.18-alpha.3] - 2026-09-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Recoverable Intercom disconnects during lazy initialization no longer print a misleading error and stack trace in the interactive UI; later calls still retry initialization.
|
|
12
|
+
- Fixed workflow invocation control across isolated workflow-owned subgroups: canonical pending/running roster discovery, queued pending sends, immediate live send/ask with correlated replies, immutable registration-based directory authorization after mutable joins, directional sibling and cross-run isolation, and invocation-unique group names ([#2784](https://github.com/bastani-inc/atomic/issues/2784)).
|
|
13
|
+
- Durably queued pending-stage sends are now rendered as successful sends instead of failed sends ([#2784](https://github.com/bastani-inc/atomic/issues/2784)).
|
|
14
|
+
|
|
7
15
|
## [0.9.17] - 2026-08-29
|
|
8
16
|
|
|
9
17
|
Cumulative release of the `0.9.17-alpha.1` prerelease. The summary below covers the user-visible outcome of that work; the per-change detail remains in the prerelease section below.
|
|
@@ -367,11 +367,11 @@ Only registered in sessions where `pi-subagents` supplied the required child bri
|
|
|
367
367
|
|
|
368
368
|
**`list`** — Keeps session-listing semantics: returns the current session and every active session sharing at least one membership. Pass `group` for a read-only view of one group.
|
|
369
369
|
|
|
370
|
-
Live target lookup accepts only an exact full session ID or an exact case-insensitive session name.
|
|
370
|
+
Live target lookup accepts only an exact full Intercom session ID or an exact case-insensitive session name. `intercom list` also returns materialized workflow-stage rows labeled `PENDING` or `RUNNING`, with canonical `<runId>:<stageId>` targets and actual groups. From the main chat, join `workflow:<rootRunId>` before controlling those targets. An eligible invocation member has directional list/send/live-ask control over stages in owned `workflow:<rootRunId>/...` subgroups. A subgroup stage or another workflow root cannot turn a mutable join into parent control; sibling subgroups stay isolated, and explicit `group: "default"` remains non-owned.
|
|
371
371
|
|
|
372
|
-
**`send`** — Sends through ordinary Intercom. Live workflow-stage sessions receive messages immediately and return `delivered`.
|
|
372
|
+
**`send`** — Sends through ordinary Intercom. Live workflow-stage sessions receive messages immediately and return `delivered`. Known pending invocation-owned stages queue durably and return the distinct `queued` result with its FIFO position; a main-chat session that explicitly joined the owning invocation group may use that route. Pending `group: "default"` stages remain ineligible, and an ineligible attempt is refused with `Target workflow run is in a different intercom group`. Each exact run/stage key retains at most 50 queued messages; the next send is refused rather than evicting an older one. Delivery occurs before the first model turn under **Messages received before you started**, with sender identity and `Sent:` timestamp separate from the task prompt. Resume/replay, broker restart, and stage-attempt restart preserve exactly-once delivery by logical message ID. Skipped, cancelled, and terminal-before-initialization destinations make pending messages undeliverable and notify the sender. If the sender reconnects with a new broker UUID, notification fallback requires one unique same-group match for the immutable registration-time name; mutable presence names/groups, cross-group matches, and ambiguous duplicates are rejected. The broker trusts that initial name/group as host-orchestration metadata, while the original display/provenance remains unchanged in the durable record. Live sends remain immediate by default; `confirmSend: true` still enables confirmation for non-reply sends.
|
|
373
373
|
|
|
374
|
-
**`ask`** — Sends a message and waits for a live recipient to reply (10-minute timeout).
|
|
374
|
+
**`ask`** — Sends a message and waits for a live recipient to reply (10-minute timeout). Invocation control supports a live ask into an owned isolated subgroup, and the exact broker-recorded reply resolves the waiting tool call at the asker without opening reverse or lateral group access. Ask to an uninitialized stage remains refused with `pending_stage_ask_unsupported`; use queued `send` instead, because holding a reply waiter until a stage eventually starts would be unbounded. A recipient disconnect after live delivery fails only that peer's exact wait promptly; the timeout remains the backstop while the recipient stays connected. Up to `maxPendingAsks` blocking asks (default: 6) may run concurrently, including same-target and mixed-target fan-out. Replies resolve by exact sender and message ID, so out-of-order replies cannot cross-settle another call. When capacity is full, new asks receive a structured refusal.
|
|
375
375
|
|
|
376
376
|
**`reply`** — Replies to the current intercom-triggered message if there is one. Otherwise it falls back to the single unresolved inbound ask. If multiple asks are pending, pass an exact name/full session ID in `to`, or the listed message ID in `replyTo`; use `pending` to inspect them first. `replyTo` also disambiguates multiple asks from the same sender. Under the hood this is still a normal `send` with the exact `replyTo` value.
|
|
377
377
|
|
|
@@ -6,7 +6,14 @@ import { writeFileSync, unlinkSync, mkdirSync, readFileSync } from "fs";
|
|
|
6
6
|
import { randomUUID } from "crypto";
|
|
7
7
|
import { writeMessage, createMessageReader } from "./framing.js";
|
|
8
8
|
import { getBrokerPidPath, getBrokerSocketPath, getIntercomDirPath } from "./paths.js";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
SessionInfo,
|
|
11
|
+
Message,
|
|
12
|
+
BrokerMessage,
|
|
13
|
+
SupervisorRegistration,
|
|
14
|
+
WorkflowStageRosterAnnouncement,
|
|
15
|
+
WorkflowStageRosterEntry,
|
|
16
|
+
} from "../types.js";
|
|
10
17
|
import { DeliveredMessageCache } from "./delivered-message-cache.js";
|
|
11
18
|
import { isMessage } from "./client-message-validation.js";
|
|
12
19
|
import { buildMessageSendSignature } from "./send-signature.js";
|
|
@@ -40,6 +47,13 @@ interface PendingStageRouteRegistration {
|
|
|
40
47
|
readonly capability: string;
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
interface WorkflowRosterRegistration {
|
|
51
|
+
readonly ownerSessionId: string;
|
|
52
|
+
readonly group: string;
|
|
53
|
+
readonly stages: WorkflowStageRosterAnnouncement[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
43
57
|
interface PendingStageAcknowledgment {
|
|
44
58
|
readonly ownerSessionId: string;
|
|
45
59
|
readonly senderSocket: net.Socket;
|
|
@@ -116,6 +130,31 @@ function isSupervisorRegistration(value: unknown): value is SupervisorRegistrati
|
|
|
116
130
|
&& typeof registration.supervisorSessionId === "string";
|
|
117
131
|
}
|
|
118
132
|
|
|
133
|
+
function invocationOwnsGroup(invocationGroup: string, candidateGroup: string): boolean {
|
|
134
|
+
const owner = normalizeGroup(invocationGroup);
|
|
135
|
+
const candidate = normalizeGroup(candidateGroup);
|
|
136
|
+
return candidate === owner || candidate.startsWith(`${owner}/`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isWorkflowStageRosterAnnouncements(value: unknown, runId: string): value is WorkflowStageRosterAnnouncement[] {
|
|
140
|
+
return (
|
|
141
|
+
Array.isArray(value) &&
|
|
142
|
+
value.every(
|
|
143
|
+
(stage) =>
|
|
144
|
+
typeof stage === "object" &&
|
|
145
|
+
stage !== null &&
|
|
146
|
+
typeof (stage as WorkflowStageRosterAnnouncement).stageId === "string" &&
|
|
147
|
+
typeof (stage as WorkflowStageRosterAnnouncement).stageName === "string" &&
|
|
148
|
+
(stage as WorkflowStageRosterAnnouncement).target ===
|
|
149
|
+
`${runId}:${(stage as WorkflowStageRosterAnnouncement).stageId}` &&
|
|
150
|
+
((stage as WorkflowStageRosterAnnouncement).lifecycle === "pending" ||
|
|
151
|
+
(stage as WorkflowStageRosterAnnouncement).lifecycle === "running") &&
|
|
152
|
+
typeof (stage as WorkflowStageRosterAnnouncement).routeEligible === "boolean" &&
|
|
153
|
+
typeof (stage as WorkflowStageRosterAnnouncement).group === "string"
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
119
158
|
|
|
120
159
|
class IntercomBroker {
|
|
121
160
|
private sessions = new Map<string, ConnectedSession>();
|
|
@@ -128,6 +167,7 @@ class IntercomBroker {
|
|
|
128
167
|
private pendingStageAcknowledgments = new Map<string, PendingStageAcknowledgment>();
|
|
129
168
|
private pendingStageNotificationAcknowledgments = new Map<string, PendingStageNotificationAcknowledgment>();
|
|
130
169
|
private liveWorkflowStageRoutes = new Map<string, LiveWorkflowStageRouteRegistration>();
|
|
170
|
+
private workflowRosters = new Map<string, WorkflowRosterRegistration>();
|
|
131
171
|
private liveWorkflowStageRouteActivations = new Map<string, LiveWorkflowStageRouteActivation>();
|
|
132
172
|
|
|
133
173
|
constructor() {
|
|
@@ -203,6 +243,74 @@ class IntercomBroker {
|
|
|
203
243
|
return undefined;
|
|
204
244
|
};
|
|
205
245
|
|
|
246
|
+
private canInspectSelectedGroup(requester: ConnectedSession, selectedGroup: string): boolean {
|
|
247
|
+
const selected = normalizeGroup(selectedGroup);
|
|
248
|
+
const groups = sessionGroups(requester.info);
|
|
249
|
+
for (const roster of this.workflowRosters.values()) {
|
|
250
|
+
if (!roster.stages.some((stage) => stage.group === selected && invocationOwnsGroup(roster.group, selected))) continue;
|
|
251
|
+
return groups.has(selected) || this.canControlWorkflowInvocation(requester, roster.group);
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Invocation control may come from the invocation owner or from an ordinary
|
|
258
|
+
* host session that explicitly joined the invocation group. A workflow stage
|
|
259
|
+
* registered under any workflow group cannot turn mutable membership into a
|
|
260
|
+
* parent-control capability for a sibling (or for another workflow run).
|
|
261
|
+
*/
|
|
262
|
+
private canControlWorkflowInvocation(sender: ConnectedSession, invocationGroup: string): boolean {
|
|
263
|
+
if (!hasGroup(sessionGroups(sender.info), invocationGroup)) return false;
|
|
264
|
+
const registrationGroup = normalizeGroup(sender.registrationGroup);
|
|
265
|
+
return registrationGroup === invocationGroup || !registrationGroup.startsWith("workflow:");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private canControlLiveWorkflowStage = (
|
|
269
|
+
sender: ConnectedSession,
|
|
270
|
+
target: ConnectedSession,
|
|
271
|
+
logicalTarget: string,
|
|
272
|
+
): boolean => {
|
|
273
|
+
const parsed = parsePendingStageTarget(logicalTarget);
|
|
274
|
+
const owner = parsed === undefined ? undefined : this.pendingStageRoutes.get(parsed.runId);
|
|
275
|
+
const live = this.liveWorkflowStageRoutes.get(logicalTarget);
|
|
276
|
+
if (owner === undefined || live?.sessionId !== target.info.id) return false;
|
|
277
|
+
const targetGroup = target.registrationGroup ?? target.info.group ?? "default";
|
|
278
|
+
return this.canControlWorkflowInvocation(sender, owner.group) && invocationOwnsGroup(owner.group, targetGroup);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
private workflowStagesVisibleTo(requester: ConnectedSession, selectedGroup?: string): WorkflowStageRosterEntry[] {
|
|
282
|
+
const requesterGroups = sessionGroups(requester.info);
|
|
283
|
+
const selected = selectedGroup === undefined ? undefined : normalizeGroup(selectedGroup);
|
|
284
|
+
const entries: WorkflowStageRosterEntry[] = [];
|
|
285
|
+
for (const [runId, roster] of this.workflowRosters) {
|
|
286
|
+
for (const stage of roster.stages) {
|
|
287
|
+
if (!stage.routeEligible) continue;
|
|
288
|
+
const parentControl =
|
|
289
|
+
this.canControlWorkflowInvocation(requester, roster.group) && invocationOwnsGroup(roster.group, stage.group);
|
|
290
|
+
const directMembership = requesterGroups.has(stage.group);
|
|
291
|
+
if (!parentControl && !directMembership) continue;
|
|
292
|
+
if (selected !== undefined && selected !== stage.group) continue;
|
|
293
|
+
const live = this.liveWorkflowStageRoutes.get(stage.target);
|
|
294
|
+
const liveSession = live === undefined ? undefined : this.sessions.get(live.sessionId);
|
|
295
|
+
if (stage.lifecycle === "running" && liveSession === undefined) continue;
|
|
296
|
+
// #2784: never list the requester's own stage as an "other" participant.
|
|
297
|
+
// Ordinary session rows already exclude self; the roster must match.
|
|
298
|
+
if (liveSession !== undefined && liveSession.info.id === requester.info.id) continue;
|
|
299
|
+
entries.push({
|
|
300
|
+
kind: "workflow-stage",
|
|
301
|
+
runId,
|
|
302
|
+
stageId: stage.stageId,
|
|
303
|
+
stageName: stage.stageName,
|
|
304
|
+
target: stage.target,
|
|
305
|
+
lifecycle: liveSession === undefined ? "pending" : "running",
|
|
306
|
+
group: stage.group,
|
|
307
|
+
...(liveSession === undefined ? {} : { sessionId: liveSession.info.id }),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return entries;
|
|
312
|
+
}
|
|
313
|
+
|
|
206
314
|
private acknowledgeLiveWorkflowStageRoute(requestId: string): void {
|
|
207
315
|
const activation = this.liveWorkflowStageRouteActivations.get(requestId);
|
|
208
316
|
if (activation === undefined || activation.pendingRequestIds.size > 0) return;
|
|
@@ -258,7 +366,7 @@ class IntercomBroker {
|
|
|
258
366
|
this.pendingStageRoutes.delete(route.runId);
|
|
259
367
|
return false;
|
|
260
368
|
}
|
|
261
|
-
|
|
369
|
+
if (route.liveTargetId === undefined && !this.canControlWorkflowInvocation(route.from, ownerRegistration.group)) {
|
|
262
370
|
writeMessage(route.socket, {
|
|
263
371
|
type: "delivery_failed",
|
|
264
372
|
messageId: route.message.id,
|
|
@@ -659,10 +767,18 @@ class IntercomBroker {
|
|
|
659
767
|
|
|
660
768
|
const requester = currentId ? this.sessions.get(currentId) : undefined;
|
|
661
769
|
if (requester === undefined) throw new Error("Session not found");
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
770
|
+
const sessions =
|
|
771
|
+
typeof clientMessage.group === "string"
|
|
772
|
+
? this.canInspectSelectedGroup(requester, clientMessage.group)
|
|
773
|
+
? sessionsInGroup(this.sessions, clientMessage.group)
|
|
774
|
+
: []
|
|
775
|
+
: sessionsVisibleTo(this.sessions, requester.info);
|
|
776
|
+
writeMessage(socket, {
|
|
777
|
+
type: "sessions",
|
|
778
|
+
requestId: clientMessage.requestId,
|
|
779
|
+
sessions,
|
|
780
|
+
workflowStages: this.workflowStagesVisibleTo(requester, clientMessage.group),
|
|
781
|
+
});
|
|
666
782
|
break;
|
|
667
783
|
}
|
|
668
784
|
|
|
@@ -726,10 +842,26 @@ class IntercomBroker {
|
|
|
726
842
|
socket.end();
|
|
727
843
|
return;
|
|
728
844
|
}
|
|
845
|
+
if (
|
|
846
|
+
clientMessage.stages !== undefined &&
|
|
847
|
+
(!isWorkflowStageRosterAnnouncements(clientMessage.stages, clientMessage.runId) ||
|
|
848
|
+
!clientMessage.stages.every((stage) => invocationOwnsGroup(ownerGroup, stage.group)))
|
|
849
|
+
) {
|
|
850
|
+
writeMessage(socket, { type: "registration_failed", reason: "Invalid workflow-stage roster" });
|
|
851
|
+
socket.end();
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
729
854
|
if (activeExisting !== undefined && activeExisting.sessionId !== currentId) {
|
|
730
|
-
// A stage replays the process-shared owner announcement before
|
|
731
|
-
//
|
|
732
|
-
//
|
|
855
|
+
// A stage replays the process-shared owner announcement before registering its live aliases.
|
|
856
|
+
// It may publish the materialized roster, but the original workflow owner must continue to
|
|
857
|
+
// handle pending delivery and own roster cleanup.
|
|
858
|
+
if (clientMessage.stages !== undefined) {
|
|
859
|
+
this.workflowRosters.set(clientMessage.runId, {
|
|
860
|
+
ownerSessionId: activeExisting.sessionId,
|
|
861
|
+
group: ownerGroup,
|
|
862
|
+
stages: clientMessage.stages,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
733
865
|
break;
|
|
734
866
|
}
|
|
735
867
|
this.pendingStageRoutes.set(clientMessage.runId, {
|
|
@@ -737,6 +869,13 @@ class IntercomBroker {
|
|
|
737
869
|
group: ownerGroup,
|
|
738
870
|
capability: clientMessage.capability,
|
|
739
871
|
});
|
|
872
|
+
if (clientMessage.stages !== undefined) {
|
|
873
|
+
this.workflowRosters.set(clientMessage.runId, {
|
|
874
|
+
ownerSessionId: currentId,
|
|
875
|
+
group: ownerGroup,
|
|
876
|
+
stages: clientMessage.stages,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
740
879
|
break;
|
|
741
880
|
}
|
|
742
881
|
|
|
@@ -764,8 +903,10 @@ class IntercomBroker {
|
|
|
764
903
|
ownerRegistration === undefined ||
|
|
765
904
|
registeringSession === undefined ||
|
|
766
905
|
ownerRegistration.capability !== clientMessage.capability ||
|
|
767
|
-
|
|
768
|
-
|
|
906
|
+
!invocationOwnsGroup(
|
|
907
|
+
ownerRegistration.group,
|
|
908
|
+
registeringSession.registrationGroup ?? registeringSession.info.group ?? "default",
|
|
909
|
+
) ||
|
|
769
910
|
!this.registerLiveWorkflowStageRoute(
|
|
770
911
|
currentId,
|
|
771
912
|
clientMessage.requestId,
|
|
@@ -821,6 +962,7 @@ class IntercomBroker {
|
|
|
821
962
|
this.pendingQuestions,
|
|
822
963
|
this.routePendingStage,
|
|
823
964
|
this.resolveLiveWorkflowStage,
|
|
965
|
+
this.canControlLiveWorkflowStage,
|
|
824
966
|
);
|
|
825
967
|
break;
|
|
826
968
|
}
|
|
@@ -873,7 +1015,9 @@ class IntercomBroker {
|
|
|
873
1015
|
}
|
|
874
1016
|
|
|
875
1017
|
for (const [runId, owner] of this.pendingStageRoutes) {
|
|
876
|
-
if (owner.sessionId
|
|
1018
|
+
if (owner.sessionId !== sessionId) continue;
|
|
1019
|
+
this.pendingStageRoutes.delete(runId);
|
|
1020
|
+
this.workflowRosters.delete(runId);
|
|
877
1021
|
}
|
|
878
1022
|
for (const [requestId, pending] of this.pendingStageAcknowledgments) {
|
|
879
1023
|
if (pending.ownerSessionId !== sessionId) continue;
|
|
@@ -3,7 +3,16 @@ import net from "net";
|
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
4
|
import { writeMessage, createMessageReader } from "./framing.js";
|
|
5
5
|
import { getBrokerSocketPath } from "./paths.js";
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
SessionInfo,
|
|
8
|
+
Message,
|
|
9
|
+
Attachment,
|
|
10
|
+
GroupSummary,
|
|
11
|
+
SupervisorRegistration,
|
|
12
|
+
SessionDirectory,
|
|
13
|
+
WorkflowStageRosterAnnouncement,
|
|
14
|
+
WorkflowStageRosterEntry,
|
|
15
|
+
} from "../types.js";
|
|
7
16
|
import { buildSendSignature, PendingSendRegistry } from "./pending-send-registry.js";
|
|
8
17
|
import { readSubagentMessageSource } from "../source-ownership.js";
|
|
9
18
|
import { isMessage, isSessionInfo } from "./client-message-validation.js";
|
|
@@ -74,6 +83,27 @@ function toError(error: unknown): Error {
|
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
|
|
86
|
+
function isWorkflowStageRosterEntries(value: unknown): value is WorkflowStageRosterEntry[] {
|
|
87
|
+
return (
|
|
88
|
+
Array.isArray(value) &&
|
|
89
|
+
value.every(
|
|
90
|
+
(entry) =>
|
|
91
|
+
typeof entry === "object" &&
|
|
92
|
+
entry !== null &&
|
|
93
|
+
(entry as WorkflowStageRosterEntry).kind === "workflow-stage" &&
|
|
94
|
+
typeof (entry as WorkflowStageRosterEntry).runId === "string" &&
|
|
95
|
+
typeof (entry as WorkflowStageRosterEntry).stageId === "string" &&
|
|
96
|
+
typeof (entry as WorkflowStageRosterEntry).stageName === "string" &&
|
|
97
|
+
typeof (entry as WorkflowStageRosterEntry).target === "string" &&
|
|
98
|
+
((entry as WorkflowStageRosterEntry).lifecycle === "pending" ||
|
|
99
|
+
(entry as WorkflowStageRosterEntry).lifecycle === "running") &&
|
|
100
|
+
typeof (entry as WorkflowStageRosterEntry).group === "string" &&
|
|
101
|
+
((entry as WorkflowStageRosterEntry).sessionId === undefined ||
|
|
102
|
+
typeof (entry as WorkflowStageRosterEntry).sessionId === "string"),
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
export class IntercomClient extends EventEmitter {
|
|
78
108
|
private readonly returnAddress: string;
|
|
79
109
|
private socket: net.Socket | null = null;
|
|
@@ -83,7 +113,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
83
113
|
private _messageSource: Message["source"] | undefined;
|
|
84
114
|
private pendingSends = new PendingSendRegistry();
|
|
85
115
|
private pendingGroupLists = new Map<string, { resolve: (groups: GroupSummary[]) => void; reject: (error: Error) => void }>();
|
|
86
|
-
private pendingLists = new Map<string, { resolve: (
|
|
116
|
+
private pendingLists = new Map<string, { resolve: (directory: SessionDirectory) => void; reject: (e: Error) => void }>();
|
|
87
117
|
private pendingPresence = new Map<string, {
|
|
88
118
|
resolve: (group: string) => void;
|
|
89
119
|
reject: (error: Error) => void;
|
|
@@ -329,19 +359,21 @@ export class IntercomClient extends EventEmitter {
|
|
|
329
359
|
break;
|
|
330
360
|
}
|
|
331
361
|
case "sessions": {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
362
|
+
const { requestId, sessions, workflowStages } = brokerMessage;
|
|
363
|
+
if (
|
|
364
|
+
typeof requestId !== "string" ||
|
|
365
|
+
!Array.isArray(sessions) ||
|
|
366
|
+
!sessions.every(isSessionInfo) ||
|
|
367
|
+
(workflowStages !== undefined && !isWorkflowStageRosterEntries(workflowStages))
|
|
368
|
+
) {
|
|
369
|
+
throw new Error("Invalid sessions message");
|
|
370
|
+
}
|
|
371
|
+
const pending = this.pendingLists.get(requestId);
|
|
372
|
+
if (!pending) return;
|
|
373
|
+
this.pendingLists.delete(requestId);
|
|
374
|
+
pending.resolve({ sessions, workflowStages: workflowStages ?? [] });
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
345
377
|
case "groups": {
|
|
346
378
|
const { requestId, groups } = brokerMessage;
|
|
347
379
|
if (
|
|
@@ -602,38 +634,41 @@ export class IntercomClient extends EventEmitter {
|
|
|
602
634
|
}
|
|
603
635
|
});
|
|
604
636
|
}
|
|
605
|
-
listSessions(group?: string): Promise<SessionInfo[]> {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
+
async listSessions(group?: string): Promise<SessionInfo[]> {
|
|
638
|
+
return (await this.listDirectory(group)).sessions;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
listDirectory(group?: string): Promise<SessionDirectory> {
|
|
642
|
+
let socket: net.Socket;
|
|
643
|
+
try {
|
|
644
|
+
socket = this.requireActiveSocket();
|
|
645
|
+
} catch (error) {
|
|
646
|
+
return Promise.reject(toError(error));
|
|
647
|
+
}
|
|
648
|
+
return new Promise((resolve, reject) => {
|
|
649
|
+
const requestId = randomUUID();
|
|
650
|
+
const timeout = setTimeout(() => {
|
|
651
|
+
if (!this.pendingLists.delete(requestId)) return;
|
|
652
|
+
reject(new Error("List sessions timeout"));
|
|
653
|
+
}, 5000);
|
|
654
|
+
this.pendingLists.set(requestId, {
|
|
655
|
+
resolve: (directory) => {
|
|
656
|
+
clearTimeout(timeout);
|
|
657
|
+
resolve(directory);
|
|
658
|
+
},
|
|
659
|
+
reject: (error) => {
|
|
660
|
+
clearTimeout(timeout);
|
|
661
|
+
reject(error);
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
try {
|
|
665
|
+
writeMessage(socket, group === undefined ? { type: "list", requestId } : { type: "list", requestId, group });
|
|
666
|
+
} catch (error) {
|
|
667
|
+
clearTimeout(timeout);
|
|
668
|
+
this.pendingLists.delete(requestId);
|
|
669
|
+
reject(toError(error));
|
|
670
|
+
}
|
|
671
|
+
});
|
|
637
672
|
}
|
|
638
673
|
listGroups(): Promise<GroupSummary[]> {
|
|
639
674
|
let socket: net.Socket;
|
|
@@ -728,8 +763,13 @@ export class IntercomClient extends EventEmitter {
|
|
|
728
763
|
}
|
|
729
764
|
});
|
|
730
765
|
}
|
|
731
|
-
registerPendingStageRoute(
|
|
732
|
-
|
|
766
|
+
registerPendingStageRoute(
|
|
767
|
+
runId: string,
|
|
768
|
+
group: string,
|
|
769
|
+
capability: string,
|
|
770
|
+
stages?: WorkflowStageRosterAnnouncement[],
|
|
771
|
+
): void {
|
|
772
|
+
writeMessage(this.requireActiveSocket(), { type: "register_pending_stage_route", runId, group, capability, stages });
|
|
733
773
|
}
|
|
734
774
|
|
|
735
775
|
registerLiveWorkflowStageRoute(runId: string, stageKeys: readonly string[], capability: string): Promise<void> {
|
|
@@ -15,6 +15,16 @@ export class PendingQuestionIndex {
|
|
|
15
15
|
this.questions.set(messageId, routes);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Authorize only the exact reverse route of a broker-recorded live ask. */
|
|
19
|
+
matchesReply(senderSessionId: string, targetSessionId: string, replyTo: string): boolean {
|
|
20
|
+
return (
|
|
21
|
+
this.questions
|
|
22
|
+
.get(replyTo)
|
|
23
|
+
?.some(
|
|
24
|
+
(route) => route.targetSessionId === senderSessionId && route.senderSessionId === targetSessionId,
|
|
25
|
+
) === true
|
|
26
|
+
);
|
|
27
|
+
}
|
|
18
28
|
clearReply(senderSessionId: string, targetSessionId: string, replyTo: string): boolean {
|
|
19
29
|
const routes = this.questions.get(replyTo);
|
|
20
30
|
const index = routes?.findIndex(
|
|
@@ -42,6 +42,11 @@ export interface PendingStageRoute {
|
|
|
42
42
|
export type PendingStageRouter = (route: PendingStageRoute) => boolean;
|
|
43
43
|
|
|
44
44
|
export type LiveWorkflowStageResolver = (target: string) => BrokerConnectedSession | undefined;
|
|
45
|
+
export type LiveWorkflowStageController = (
|
|
46
|
+
sender: BrokerConnectedSession,
|
|
47
|
+
target: BrokerConnectedSession,
|
|
48
|
+
logicalTarget: string,
|
|
49
|
+
) => boolean;
|
|
45
50
|
|
|
46
51
|
const WORKFLOW_RUN_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
47
52
|
|
|
@@ -56,6 +61,8 @@ export const PENDING_STAGE_ASK_REFUSAL =
|
|
|
56
61
|
"Cannot ask a workflow stage whose session has not initialized. Use send; Atomic will queue the message until the stage session initializes.";
|
|
57
62
|
|
|
58
63
|
|
|
64
|
+
|
|
65
|
+
|
|
59
66
|
interface SendClientMessage extends Record<string, unknown> {
|
|
60
67
|
type: string;
|
|
61
68
|
}
|
|
@@ -68,16 +75,17 @@ function wireMessageId(value: unknown): string {
|
|
|
68
75
|
|
|
69
76
|
/** Validate and route one wire-level send request. */
|
|
70
77
|
export function handleBrokerSend(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
socket: net.Socket,
|
|
79
|
+
clientMessage: SendClientMessage,
|
|
80
|
+
currentId: string | null,
|
|
81
|
+
sessions: Map<string, BrokerConnectedSession>,
|
|
82
|
+
deliveredMessages: DeliveredMessageCache,
|
|
83
|
+
write: (target: net.Socket, message: BrokerMessage) => void,
|
|
84
|
+
supervisorCache: SupervisorChannelCache = new SupervisorChannelCache(),
|
|
85
|
+
pendingQuestions: PendingQuestionIndex = new PendingQuestionIndex(),
|
|
86
|
+
routePendingStage?: PendingStageRouter,
|
|
87
|
+
resolveLiveWorkflowStage?: LiveWorkflowStageResolver,
|
|
88
|
+
canControlLiveWorkflowStage?: LiveWorkflowStageController,
|
|
81
89
|
): void {
|
|
82
90
|
const message = clientMessage.message;
|
|
83
91
|
const messageId = wireMessageId(message);
|
|
@@ -158,13 +166,20 @@ export function handleBrokerSend(
|
|
|
158
166
|
write(socket, { type: "delivery_failed", messageId: message.id, attemptId, reason: "Cannot message the current session" });
|
|
159
167
|
return;
|
|
160
168
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
const correlatedReply =
|
|
170
|
+
message.replyTo !== undefined &&
|
|
171
|
+
pendingQuestions.matchesReply(fromSession.info.id, target.info.id, message.replyTo);
|
|
172
|
+
const bypass =
|
|
173
|
+
supervisorSend ||
|
|
174
|
+
isVerticalBypass({
|
|
175
|
+
replyTo: message.replyTo,
|
|
176
|
+
sender: fromSession.info,
|
|
177
|
+
target: target.info,
|
|
178
|
+
supervisorCache,
|
|
179
|
+
}) ||
|
|
180
|
+
correlatedReply ||
|
|
181
|
+
(liveWorkflowTarget !== undefined && canControlLiveWorkflowStage?.(fromSession, target, trimmedTo) === true);
|
|
182
|
+
if (!bypass && !sameGroup(target.info, fromSession.info)) {
|
|
168
183
|
write(socket, {
|
|
169
184
|
type: "delivery_failed",
|
|
170
185
|
messageId: message.id,
|