@tomflow/proflow-platform-host 0.1.30 → 0.1.31
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/CHANGELOG.md +6 -0
- package/dist/deployment/adapter.d.ts +10 -10
- package/dist/deployment/adapter.js +22 -14
- package/dist/deployment/descriptor.d.ts +1 -1
- package/dist/deployment/descriptor.js +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +437 -15
- package/dist/src/monitor-rotation-coordinator.d.ts +76 -0
- package/dist/src/monitor-rotation-coordinator.js +782 -0
- package/dist/src/product-campaign-continuation.d.ts +132 -0
- package/dist/src/product-campaign-continuation.js +279 -0
- package/dist/src/product-discussion-admission.d.ts +92 -0
- package/dist/src/product-discussion-admission.js +191 -0
- package/dist/src/product-discussion-host.d.ts +43 -0
- package/dist/src/product-discussion-host.js +167 -0
- package/dist/src/reconciliation-coordinator.d.ts +1 -0
- package/dist/src/reconciliation-coordinator.js +7 -2
- package/dist/src/role-operations.js +8 -0
- package/package.json +9 -9
- package/proflow.module.json +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export declare const productDiscussionQueryOperationIds: Set<string>;
|
|
2
|
+
export declare const productDiscussionMutationOperationIds: Set<string>;
|
|
3
|
+
export type ProductRoleBinding = {
|
|
4
|
+
agentPackageRef: string;
|
|
5
|
+
roleRef: string;
|
|
6
|
+
};
|
|
7
|
+
export type ProductDiscussionSessionFact = {
|
|
8
|
+
discussionRef: string;
|
|
9
|
+
roleRef: string;
|
|
10
|
+
conversationRef: string;
|
|
11
|
+
conversationLocator: string;
|
|
12
|
+
campaignRef: string | null;
|
|
13
|
+
goalRevision: number | null;
|
|
14
|
+
currentIntentRef: string | null;
|
|
15
|
+
generation: number;
|
|
16
|
+
status: "ACTIVE" | "CLOSED";
|
|
17
|
+
version: number;
|
|
18
|
+
};
|
|
19
|
+
export type ProductDiscussionAdmissionVerificationInput = {
|
|
20
|
+
discussionRef: string;
|
|
21
|
+
roleRef: string;
|
|
22
|
+
conversationRef: string;
|
|
23
|
+
conversationLocator: string;
|
|
24
|
+
generation: number;
|
|
25
|
+
discussionAdmissionToken: string;
|
|
26
|
+
};
|
|
27
|
+
export type ProductContinuationProjection = {
|
|
28
|
+
campaign: {
|
|
29
|
+
campaignRef: string;
|
|
30
|
+
discussionRef: string;
|
|
31
|
+
goalRevision: number;
|
|
32
|
+
version: number;
|
|
33
|
+
status: "OPEN" | "CLOSED";
|
|
34
|
+
};
|
|
35
|
+
intent: {
|
|
36
|
+
intentRef: string;
|
|
37
|
+
version: number;
|
|
38
|
+
} | null;
|
|
39
|
+
task: {
|
|
40
|
+
taskId: string;
|
|
41
|
+
status: string;
|
|
42
|
+
version: number;
|
|
43
|
+
} | null;
|
|
44
|
+
authorization: {
|
|
45
|
+
authorizationRef: string;
|
|
46
|
+
version: number;
|
|
47
|
+
} | null;
|
|
48
|
+
requirementRevision: number | null;
|
|
49
|
+
nextAction: "NONE" | "PROVISION" | "TRANSFER_REQUIREMENT" | "CONSIDER_START" | "PRODUCT_REVIEW_REQUIRED";
|
|
50
|
+
};
|
|
51
|
+
export type ProductReviewSignal = {
|
|
52
|
+
kind: "PRODUCT_REVIEW_REQUIRED";
|
|
53
|
+
reviewRef: string;
|
|
54
|
+
campaignRef: string;
|
|
55
|
+
taskId: string;
|
|
56
|
+
terminalTaskVersion: number;
|
|
57
|
+
intentRef: string;
|
|
58
|
+
discussionRef: string;
|
|
59
|
+
discussionGeneration: number;
|
|
60
|
+
roleRef: string;
|
|
61
|
+
conversationRef: string;
|
|
62
|
+
conversationLocator: string;
|
|
63
|
+
};
|
|
64
|
+
export type ProductContinuationOutcome = {
|
|
65
|
+
kind: "NOT_CAMPAIGN";
|
|
66
|
+
taskId: string;
|
|
67
|
+
} | {
|
|
68
|
+
kind: "IDLE";
|
|
69
|
+
taskId: string;
|
|
70
|
+
} | {
|
|
71
|
+
kind: "AUTHORIZATION_REQUIRED";
|
|
72
|
+
taskId: string;
|
|
73
|
+
} | {
|
|
74
|
+
kind: "TASK_STARTED";
|
|
75
|
+
taskId: string;
|
|
76
|
+
} | ProductReviewSignal;
|
|
77
|
+
export declare class ProductCampaignContinuationError extends Error {
|
|
78
|
+
readonly code: string;
|
|
79
|
+
readonly httpStatus: number;
|
|
80
|
+
constructor(code: string, httpStatus?: number);
|
|
81
|
+
}
|
|
82
|
+
export declare function canonicalizeProductOperation(input: {
|
|
83
|
+
operationId: string;
|
|
84
|
+
authenticatedRoleRef: string;
|
|
85
|
+
rawInput: unknown;
|
|
86
|
+
getDiscussionSession: (discussionRef: string) => ProductDiscussionSessionFact;
|
|
87
|
+
getRoleBindings: () => readonly ProductRoleBinding[];
|
|
88
|
+
verifyDiscussionAdmission?: (input: ProductDiscussionAdmissionVerificationInput) => boolean;
|
|
89
|
+
}): {
|
|
90
|
+
scope: "TASK" | "PRODUCT_QUERY" | "PRODUCT_MUTATION";
|
|
91
|
+
input: Record<string, unknown>;
|
|
92
|
+
actorRef?: string;
|
|
93
|
+
kickContinuation: boolean;
|
|
94
|
+
};
|
|
95
|
+
export declare function createProductCampaignContinuationCoordinator(options: {
|
|
96
|
+
getProjection: (taskId: string) => Promise<ProductContinuationProjection | null>;
|
|
97
|
+
listTaskIds: () => Promise<readonly string[]>;
|
|
98
|
+
ensureWorkers: (taskId: string) => Promise<void>;
|
|
99
|
+
transferRequirement: (input: {
|
|
100
|
+
intentRef: string;
|
|
101
|
+
taskId: string;
|
|
102
|
+
expectedIntentVersion: number;
|
|
103
|
+
expectedTaskVersion: number;
|
|
104
|
+
idempotencyKey: string;
|
|
105
|
+
}) => Promise<void>;
|
|
106
|
+
startTask: (input: {
|
|
107
|
+
taskId: string;
|
|
108
|
+
expectedTaskVersion: number;
|
|
109
|
+
authorizationRef: string;
|
|
110
|
+
expectedAuthorizationVersion: number;
|
|
111
|
+
expectedCampaignVersion: number;
|
|
112
|
+
idempotencyKey: string;
|
|
113
|
+
}) => Promise<void>;
|
|
114
|
+
getDiscussionSessionForCampaign: (campaignRef: string) => ProductDiscussionSessionFact | undefined;
|
|
115
|
+
updateDiscussionSessionContext: (input: {
|
|
116
|
+
discussionRef: string;
|
|
117
|
+
expectedVersion: number;
|
|
118
|
+
campaignRef: string;
|
|
119
|
+
goalRevision: number;
|
|
120
|
+
currentIntentRef: string;
|
|
121
|
+
}) => ProductDiscussionSessionFact;
|
|
122
|
+
kickTaskReconciliation: (taskId: string) => void;
|
|
123
|
+
onProductReviewRequired?: (signal: ProductReviewSignal) => void | Promise<void>;
|
|
124
|
+
retryDelayMs?: number;
|
|
125
|
+
}): Readonly<{
|
|
126
|
+
start(): void;
|
|
127
|
+
kick: (taskId: string) => void;
|
|
128
|
+
reconcile: (taskId: string) => Promise<ProductContinuationOutcome>;
|
|
129
|
+
sweep: () => Promise<ProductContinuationOutcome[]>;
|
|
130
|
+
drain(): Promise<void>;
|
|
131
|
+
stop(): void;
|
|
132
|
+
}>;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
export const productDiscussionQueryOperationIds = new Set([
|
|
2
|
+
"getProductCampaign",
|
|
3
|
+
"getProductDiscussionContext",
|
|
4
|
+
"getProductDocument",
|
|
5
|
+
"getCampaignAuthorization",
|
|
6
|
+
]);
|
|
7
|
+
export const productDiscussionMutationOperationIds = new Set([
|
|
8
|
+
"submitProductTaskIntent",
|
|
9
|
+
"putProductDocument",
|
|
10
|
+
"recordProductGapReview",
|
|
11
|
+
"recordProductGoalSatisfied",
|
|
12
|
+
]);
|
|
13
|
+
export class ProductCampaignContinuationError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
httpStatus;
|
|
16
|
+
constructor(code, httpStatus = 409) {
|
|
17
|
+
super(code);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.httpStatus = httpStatus;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function record(value, label) {
|
|
23
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
24
|
+
throw new ProductCampaignContinuationError(`${label.toUpperCase()}_INVALID`, 400);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function requiredString(value, label) {
|
|
28
|
+
if (typeof value !== "string" || value.length === 0)
|
|
29
|
+
throw new ProductCampaignContinuationError(`${label.toUpperCase()}_REQUIRED`, 400);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function requiredGeneration(value) {
|
|
33
|
+
if (!Number.isInteger(value) || Number(value) <= 0)
|
|
34
|
+
throw new ProductCampaignContinuationError("PRODUCT_DISCUSSION_GENERATION_REQUIRED", 400);
|
|
35
|
+
return Number(value);
|
|
36
|
+
}
|
|
37
|
+
export function canonicalizeProductOperation(input) {
|
|
38
|
+
const raw = record(input.rawInput, "product action input");
|
|
39
|
+
const productQuery = productDiscussionQueryOperationIds.has(input.operationId);
|
|
40
|
+
const productMutation = productDiscussionMutationOperationIds.has(input.operationId);
|
|
41
|
+
if (!productQuery && !productMutation)
|
|
42
|
+
return { scope: "TASK", input: raw, kickContinuation: false };
|
|
43
|
+
const canonical = { ...raw };
|
|
44
|
+
delete canonical.roleRef;
|
|
45
|
+
delete canonical.actorRef;
|
|
46
|
+
delete canonical.workerRef;
|
|
47
|
+
delete canonical.roleBindings;
|
|
48
|
+
const discussionRef = requiredString(canonical.discussionRef, "discussionRef");
|
|
49
|
+
const discussionGeneration = requiredGeneration(canonical.discussionGeneration);
|
|
50
|
+
const session = input.getDiscussionSession(discussionRef);
|
|
51
|
+
if (session.status !== "ACTIVE" ||
|
|
52
|
+
session.roleRef !== input.authenticatedRoleRef)
|
|
53
|
+
throw new ProductCampaignContinuationError("PRODUCT_DISCUSSION_MISMATCH", 403);
|
|
54
|
+
if (session.generation !== discussionGeneration)
|
|
55
|
+
throw new ProductCampaignContinuationError("PRODUCT_DISCUSSION_GENERATION_MISMATCH", 403);
|
|
56
|
+
if (!input.verifyDiscussionAdmission)
|
|
57
|
+
throw new ProductCampaignContinuationError("PRODUCT_DISCUSSION_ADMISSION_UNAVAILABLE", 503);
|
|
58
|
+
const discussionAdmissionToken = requiredString(canonical.discussionAdmissionToken, "discussionAdmissionToken");
|
|
59
|
+
if (!input.verifyDiscussionAdmission({
|
|
60
|
+
discussionRef,
|
|
61
|
+
roleRef: session.roleRef,
|
|
62
|
+
conversationRef: session.conversationRef,
|
|
63
|
+
conversationLocator: session.conversationLocator,
|
|
64
|
+
generation: discussionGeneration,
|
|
65
|
+
discussionAdmissionToken,
|
|
66
|
+
}))
|
|
67
|
+
throw new ProductCampaignContinuationError("PRODUCT_DISCUSSION_ADMISSION_INVALID", 403);
|
|
68
|
+
delete canonical.discussionAdmissionToken;
|
|
69
|
+
if (typeof canonical.campaignRef === "string" &&
|
|
70
|
+
session.campaignRef !== canonical.campaignRef)
|
|
71
|
+
throw new ProductCampaignContinuationError("PRODUCT_DISCUSSION_CAMPAIGN_MISMATCH", 403);
|
|
72
|
+
if (typeof canonical.goalRevision === "number" &&
|
|
73
|
+
session.goalRevision !== null &&
|
|
74
|
+
session.goalRevision !== canonical.goalRevision)
|
|
75
|
+
throw new ProductCampaignContinuationError("PRODUCT_DISCUSSION_GOAL_REVISION_MISMATCH", 403);
|
|
76
|
+
delete canonical.discussionGeneration;
|
|
77
|
+
if (productQuery) {
|
|
78
|
+
if (input.operationId !== "getProductDocument")
|
|
79
|
+
delete canonical.discussionRef;
|
|
80
|
+
if (input.operationId === "getProductDocument")
|
|
81
|
+
requiredString(canonical.campaignRef, "campaignRef");
|
|
82
|
+
if (input.operationId === "getCampaignAuthorization") {
|
|
83
|
+
requiredString(canonical.campaignRef, "campaignRef");
|
|
84
|
+
delete canonical.campaignRef;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
scope: "PRODUCT_QUERY",
|
|
88
|
+
input: canonical,
|
|
89
|
+
kickContinuation: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (input.operationId === "submitProductTaskIntent")
|
|
93
|
+
canonical.roleBindings = input
|
|
94
|
+
.getRoleBindings()
|
|
95
|
+
.map((binding) => ({ ...binding }));
|
|
96
|
+
return {
|
|
97
|
+
scope: "PRODUCT_MUTATION",
|
|
98
|
+
input: canonical,
|
|
99
|
+
actorRef: `product-discussion:${discussionRef}`,
|
|
100
|
+
kickContinuation: input.operationId === "submitProductTaskIntent",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export function createProductCampaignContinuationCoordinator(options) {
|
|
104
|
+
const retryDelayMs = Math.max(50, options.retryDelayMs ?? 500);
|
|
105
|
+
const inFlight = new Map();
|
|
106
|
+
const retryTimers = new Map();
|
|
107
|
+
let stopped = false;
|
|
108
|
+
let scanTimer;
|
|
109
|
+
let started = false;
|
|
110
|
+
let scanInFlight;
|
|
111
|
+
const reconcileOnce = async (taskId) => {
|
|
112
|
+
for (let step = 0; step < 6; step += 1) {
|
|
113
|
+
if (stopped)
|
|
114
|
+
return { kind: "IDLE", taskId };
|
|
115
|
+
const projection = await options.getProjection(taskId);
|
|
116
|
+
if (stopped)
|
|
117
|
+
return { kind: "IDLE", taskId };
|
|
118
|
+
if (!projection)
|
|
119
|
+
return { kind: "NOT_CAMPAIGN", taskId };
|
|
120
|
+
if (projection.nextAction === "NONE")
|
|
121
|
+
return { kind: "IDLE", taskId };
|
|
122
|
+
if (!projection.task || !projection.intent)
|
|
123
|
+
throw new ProductCampaignContinuationError("PRODUCT_CONTINUATION_FACTS_INCOMPLETE");
|
|
124
|
+
if (projection.nextAction === "PROVISION") {
|
|
125
|
+
await options.ensureWorkers(taskId);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (projection.nextAction === "TRANSFER_REQUIREMENT") {
|
|
129
|
+
if (projection.requirementRevision === null)
|
|
130
|
+
throw new ProductCampaignContinuationError("PRODUCT_REQUIREMENT_REVISION_MISSING");
|
|
131
|
+
await options.transferRequirement({
|
|
132
|
+
intentRef: projection.intent.intentRef,
|
|
133
|
+
taskId,
|
|
134
|
+
expectedIntentVersion: projection.intent.version,
|
|
135
|
+
expectedTaskVersion: projection.task.version,
|
|
136
|
+
idempotencyKey: `transfer:${projection.intent.intentRef}:${taskId}:${projection.requirementRevision}`,
|
|
137
|
+
});
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (projection.nextAction === "CONSIDER_START") {
|
|
141
|
+
if (!projection.authorization)
|
|
142
|
+
return { kind: "AUTHORIZATION_REQUIRED", taskId };
|
|
143
|
+
await options.startTask({
|
|
144
|
+
taskId,
|
|
145
|
+
expectedTaskVersion: projection.task.version,
|
|
146
|
+
authorizationRef: projection.authorization.authorizationRef,
|
|
147
|
+
expectedAuthorizationVersion: projection.authorization.version,
|
|
148
|
+
expectedCampaignVersion: projection.campaign.version,
|
|
149
|
+
idempotencyKey: `auto-start:${projection.authorization.authorizationRef}:${taskId}:${projection.task.version}`,
|
|
150
|
+
});
|
|
151
|
+
options.kickTaskReconciliation(taskId);
|
|
152
|
+
return { kind: "TASK_STARTED", taskId };
|
|
153
|
+
}
|
|
154
|
+
const session = options.getDiscussionSessionForCampaign(projection.campaign.campaignRef);
|
|
155
|
+
if (!session ||
|
|
156
|
+
session.status !== "ACTIVE" ||
|
|
157
|
+
session.discussionRef !== projection.campaign.discussionRef ||
|
|
158
|
+
session.campaignRef !== projection.campaign.campaignRef ||
|
|
159
|
+
(session.goalRevision !== null &&
|
|
160
|
+
session.goalRevision !== projection.campaign.goalRevision))
|
|
161
|
+
throw new ProductCampaignContinuationError("PRODUCT_DISCUSSION_SESSION_NOT_READY");
|
|
162
|
+
const currentSession = session.currentIntentRef === projection.intent.intentRef
|
|
163
|
+
? session
|
|
164
|
+
: options.updateDiscussionSessionContext({
|
|
165
|
+
discussionRef: session.discussionRef,
|
|
166
|
+
expectedVersion: session.version,
|
|
167
|
+
campaignRef: projection.campaign.campaignRef,
|
|
168
|
+
goalRevision: projection.campaign.goalRevision,
|
|
169
|
+
currentIntentRef: projection.intent.intentRef,
|
|
170
|
+
});
|
|
171
|
+
const signal = {
|
|
172
|
+
kind: "PRODUCT_REVIEW_REQUIRED",
|
|
173
|
+
reviewRef: `product-review:${projection.campaign.campaignRef}:${taskId}:${projection.task.version}:${currentSession.discussionRef}:${currentSession.generation}`,
|
|
174
|
+
campaignRef: projection.campaign.campaignRef,
|
|
175
|
+
taskId,
|
|
176
|
+
terminalTaskVersion: projection.task.version,
|
|
177
|
+
intentRef: projection.intent.intentRef,
|
|
178
|
+
discussionRef: currentSession.discussionRef,
|
|
179
|
+
discussionGeneration: currentSession.generation,
|
|
180
|
+
roleRef: currentSession.roleRef,
|
|
181
|
+
conversationRef: currentSession.conversationRef,
|
|
182
|
+
conversationLocator: currentSession.conversationLocator,
|
|
183
|
+
};
|
|
184
|
+
await options.onProductReviewRequired?.(signal);
|
|
185
|
+
return signal;
|
|
186
|
+
}
|
|
187
|
+
throw new ProductCampaignContinuationError("PRODUCT_CONTINUATION_STEP_LIMIT");
|
|
188
|
+
};
|
|
189
|
+
const reconcile = (taskId) => {
|
|
190
|
+
const current = inFlight.get(taskId);
|
|
191
|
+
if (current)
|
|
192
|
+
return current;
|
|
193
|
+
const run = reconcileOnce(taskId).finally(() => inFlight.delete(taskId));
|
|
194
|
+
inFlight.set(taskId, run);
|
|
195
|
+
return run;
|
|
196
|
+
};
|
|
197
|
+
const scheduleRetry = (taskId) => {
|
|
198
|
+
if (stopped || retryTimers.has(taskId))
|
|
199
|
+
return;
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
retryTimers.delete(taskId);
|
|
202
|
+
if (!stopped)
|
|
203
|
+
void reconcile(taskId).catch(() => scheduleRetry(taskId));
|
|
204
|
+
}, retryDelayMs);
|
|
205
|
+
timer.unref?.();
|
|
206
|
+
retryTimers.set(taskId, timer);
|
|
207
|
+
};
|
|
208
|
+
const kick = (taskId) => {
|
|
209
|
+
if (stopped)
|
|
210
|
+
return;
|
|
211
|
+
void reconcile(taskId).catch(() => scheduleRetry(taskId));
|
|
212
|
+
};
|
|
213
|
+
const sweep = async () => {
|
|
214
|
+
if (stopped)
|
|
215
|
+
return [];
|
|
216
|
+
const taskIds = await options.listTaskIds();
|
|
217
|
+
const outcomes = [];
|
|
218
|
+
const failures = [];
|
|
219
|
+
for (let index = 0; !stopped && index < taskIds.length; index += 4) {
|
|
220
|
+
const batch = await Promise.allSettled(taskIds.slice(index, index + 4).map((taskId) => reconcile(taskId).catch((error) => {
|
|
221
|
+
scheduleRetry(taskId);
|
|
222
|
+
throw error;
|
|
223
|
+
})));
|
|
224
|
+
for (const result of batch) {
|
|
225
|
+
if (result.status === "fulfilled")
|
|
226
|
+
outcomes.push(result.value);
|
|
227
|
+
else
|
|
228
|
+
failures.push(result.reason);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (failures.length > 0)
|
|
232
|
+
throw failures[0];
|
|
233
|
+
return outcomes;
|
|
234
|
+
};
|
|
235
|
+
const scan = async () => {
|
|
236
|
+
let delay = 30_000;
|
|
237
|
+
try {
|
|
238
|
+
await sweep();
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
delay = retryDelayMs;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
if (!stopped) {
|
|
245
|
+
scanTimer = setTimeout(() => {
|
|
246
|
+
scanInFlight = scan();
|
|
247
|
+
}, delay);
|
|
248
|
+
scanTimer.unref?.();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
return Object.freeze({
|
|
253
|
+
start() {
|
|
254
|
+
if (stopped)
|
|
255
|
+
throw new ProductCampaignContinuationError("PRODUCT_CONTINUATION_STOPPED");
|
|
256
|
+
if (started)
|
|
257
|
+
return;
|
|
258
|
+
started = true;
|
|
259
|
+
scanInFlight = scan();
|
|
260
|
+
},
|
|
261
|
+
kick,
|
|
262
|
+
reconcile,
|
|
263
|
+
sweep,
|
|
264
|
+
async drain() {
|
|
265
|
+
await Promise.allSettled([
|
|
266
|
+
...(scanInFlight ? [scanInFlight] : []),
|
|
267
|
+
...inFlight.values(),
|
|
268
|
+
]);
|
|
269
|
+
},
|
|
270
|
+
stop() {
|
|
271
|
+
stopped = true;
|
|
272
|
+
if (scanTimer)
|
|
273
|
+
clearTimeout(scanTimer);
|
|
274
|
+
for (const timer of retryTimers.values())
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
retryTimers.clear();
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export type ProductDiscussionConversationIdentity = Readonly<{
|
|
2
|
+
roleRef: string;
|
|
3
|
+
conversationRef: string;
|
|
4
|
+
conversationLocator: string;
|
|
5
|
+
}>;
|
|
6
|
+
export type ProductDiscussionAdmission = Readonly<{
|
|
7
|
+
discussionRef: string;
|
|
8
|
+
roleRef: string;
|
|
9
|
+
conversationRef: string;
|
|
10
|
+
conversationLocator: string;
|
|
11
|
+
generation: number;
|
|
12
|
+
contentInstanceId: string;
|
|
13
|
+
bootstrapRef: string;
|
|
14
|
+
discussionAdmissionToken: string;
|
|
15
|
+
}>;
|
|
16
|
+
export type ProductDiscussionAdmissionVerification = Readonly<{
|
|
17
|
+
discussionRef: string;
|
|
18
|
+
roleRef: string;
|
|
19
|
+
conversationRef: string;
|
|
20
|
+
conversationLocator: string;
|
|
21
|
+
generation: number;
|
|
22
|
+
discussionAdmissionToken: string;
|
|
23
|
+
}>;
|
|
24
|
+
export type ProductDiscussionSessionOwnerFact = Readonly<{
|
|
25
|
+
discussionRef: string;
|
|
26
|
+
roleRef: string;
|
|
27
|
+
conversationRef: string;
|
|
28
|
+
conversationLocator: string;
|
|
29
|
+
generation: number;
|
|
30
|
+
status: "ACTIVE" | "CLOSED";
|
|
31
|
+
}>;
|
|
32
|
+
export type ProductDiscussionTaskRoleBindingFact = Readonly<{
|
|
33
|
+
roleRef: string;
|
|
34
|
+
workerRef: string | null;
|
|
35
|
+
conversationLocator: string | null;
|
|
36
|
+
}>;
|
|
37
|
+
type RandomBytes = (size: number) => Uint8Array;
|
|
38
|
+
export declare function parseProductDiscussionConversationLocator(value: string): ProductDiscussionConversationIdentity | null;
|
|
39
|
+
export declare function productDiscussionRef(identity: ProductDiscussionConversationIdentity): string;
|
|
40
|
+
export declare function createProductDiscussionAdmissionAuthority(options?: {
|
|
41
|
+
randomBytesImpl?: RandomBytes;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}): Readonly<{
|
|
44
|
+
ensure: (input: {
|
|
45
|
+
discussionRef: string;
|
|
46
|
+
roleRef: string;
|
|
47
|
+
conversationRef: string;
|
|
48
|
+
conversationLocator: string;
|
|
49
|
+
generation: number;
|
|
50
|
+
contentInstanceId: string;
|
|
51
|
+
}) => ProductDiscussionAdmission;
|
|
52
|
+
verify: (input: ProductDiscussionAdmissionVerification) => boolean;
|
|
53
|
+
get(discussionRef: string): ProductDiscussionAdmission | undefined;
|
|
54
|
+
revoke(discussionRef: string): boolean;
|
|
55
|
+
clear(): void;
|
|
56
|
+
}>;
|
|
57
|
+
export declare function assertProductDiscussionConversationPreTask(options: {
|
|
58
|
+
conversationLocator: string;
|
|
59
|
+
listTaskIds(): readonly string[] | Promise<readonly string[]>;
|
|
60
|
+
getTaskRoleBindings(taskId: string): readonly ProductDiscussionTaskRoleBindingFact[] | Promise<readonly ProductDiscussionTaskRoleBindingFact[]>;
|
|
61
|
+
}): Promise<void>;
|
|
62
|
+
export declare function createProductDiscussionAdmissionCoordinator(options: {
|
|
63
|
+
productRoleRef: string;
|
|
64
|
+
authority: ReturnType<typeof createProductDiscussionAdmissionAuthority>;
|
|
65
|
+
isTaskWorkerProvisioning(roleRef: string): boolean;
|
|
66
|
+
listTaskIds(): readonly string[] | Promise<readonly string[]>;
|
|
67
|
+
getTaskRoleBindings(taskId: string): readonly ProductDiscussionTaskRoleBindingFact[] | Promise<readonly ProductDiscussionTaskRoleBindingFact[]>;
|
|
68
|
+
registerDiscussionSession(input: {
|
|
69
|
+
discussionRef: string;
|
|
70
|
+
roleRef: string;
|
|
71
|
+
conversationRef: string;
|
|
72
|
+
conversationLocator: string;
|
|
73
|
+
}): ProductDiscussionSessionOwnerFact | Promise<ProductDiscussionSessionOwnerFact>;
|
|
74
|
+
deliverBootstrap(admission: ProductDiscussionAdmission): void | Promise<void>;
|
|
75
|
+
}): Readonly<{
|
|
76
|
+
observe: (input: {
|
|
77
|
+
conversationLocator: string;
|
|
78
|
+
contentInstanceId: string;
|
|
79
|
+
}) => Promise<Readonly<{
|
|
80
|
+
admitted: false;
|
|
81
|
+
reason: "NOT_CUSTOM_GPT_CONVERSATION";
|
|
82
|
+
}> | Readonly<{
|
|
83
|
+
admitted: false;
|
|
84
|
+
reason: "NOT_PRODUCT_ROLE";
|
|
85
|
+
}> | Readonly<{
|
|
86
|
+
admitted: true;
|
|
87
|
+
discussionRef: string;
|
|
88
|
+
discussionGeneration: number;
|
|
89
|
+
bootstrapRef: string;
|
|
90
|
+
}>>;
|
|
91
|
+
}>;
|
|
92
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const productRoleRef = /^g-[A-Za-z0-9_-]+$/;
|
|
3
|
+
const bootstrapPrefix = "product-discussion-bootstrap:";
|
|
4
|
+
const tokenBytes = 32;
|
|
5
|
+
const bootstrapRefBytes = 16;
|
|
6
|
+
function requiredText(value, code) {
|
|
7
|
+
if (value.trim().length === 0)
|
|
8
|
+
throw new Error(code);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function positiveInteger(value, code) {
|
|
12
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
13
|
+
throw new Error(code);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function randomBuffer(random, size) {
|
|
17
|
+
const bytes = Buffer.from(random(size));
|
|
18
|
+
if (bytes.byteLength !== size)
|
|
19
|
+
throw new Error("PRODUCT_DISCUSSION_ADMISSION_RANDOM_SOURCE_INVALID");
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
function secretEqual(left, right) {
|
|
23
|
+
const a = Buffer.from(left);
|
|
24
|
+
const b = Buffer.from(right);
|
|
25
|
+
return a.byteLength === b.byteLength && timingSafeEqual(a, b);
|
|
26
|
+
}
|
|
27
|
+
export function parseProductDiscussionConversationLocator(value) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== "https:" ||
|
|
36
|
+
parsed.hostname !== "chatgpt.com" ||
|
|
37
|
+
parsed.username !== "" ||
|
|
38
|
+
parsed.password !== "" ||
|
|
39
|
+
parsed.search !== "" ||
|
|
40
|
+
parsed.hash !== "")
|
|
41
|
+
return null;
|
|
42
|
+
const match = /^\/g\/(g-[A-Za-z0-9_-]+)\/c\/([^/]+)$/.exec(parsed.pathname);
|
|
43
|
+
if (!match?.[1] || !match[2] || !productRoleRef.test(match[1]))
|
|
44
|
+
return null;
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
roleRef: match[1],
|
|
47
|
+
conversationRef: match[2],
|
|
48
|
+
conversationLocator: parsed.href,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function productDiscussionRef(identity) {
|
|
52
|
+
return `product-discussion:${createHash("sha256")
|
|
53
|
+
.update(JSON.stringify([
|
|
54
|
+
identity.roleRef,
|
|
55
|
+
identity.conversationRef,
|
|
56
|
+
identity.conversationLocator,
|
|
57
|
+
]))
|
|
58
|
+
.digest("hex")}`;
|
|
59
|
+
}
|
|
60
|
+
export function createProductDiscussionAdmissionAuthority(options = {}) {
|
|
61
|
+
const random = options.randomBytesImpl ?? randomBytes;
|
|
62
|
+
const admissions = new Map();
|
|
63
|
+
const expiresAt = new Map();
|
|
64
|
+
const now = options.now ?? Date.now;
|
|
65
|
+
const ttlMs = 30 * 60_000;
|
|
66
|
+
const mint = (input) => Object.freeze({
|
|
67
|
+
discussionRef: input.discussionRef,
|
|
68
|
+
roleRef: input.identity.roleRef,
|
|
69
|
+
conversationRef: input.identity.conversationRef,
|
|
70
|
+
conversationLocator: input.identity.conversationLocator,
|
|
71
|
+
generation: input.generation,
|
|
72
|
+
contentInstanceId: input.contentInstanceId,
|
|
73
|
+
bootstrapRef: `${bootstrapPrefix}${randomBuffer(random, bootstrapRefBytes).toString("hex")}`,
|
|
74
|
+
discussionAdmissionToken: randomBuffer(random, tokenBytes).toString("base64url"),
|
|
75
|
+
});
|
|
76
|
+
const ensure = (input) => {
|
|
77
|
+
const discussionRef = requiredText(input.discussionRef, "PRODUCT_DISCUSSION_REF_REQUIRED");
|
|
78
|
+
const contentInstanceId = requiredText(input.contentInstanceId, "PRODUCT_DISCUSSION_CONTENT_INSTANCE_REQUIRED");
|
|
79
|
+
const generation = positiveInteger(input.generation, "PRODUCT_DISCUSSION_GENERATION_REQUIRED");
|
|
80
|
+
const identity = parseProductDiscussionConversationLocator(input.conversationLocator);
|
|
81
|
+
if (!identity ||
|
|
82
|
+
identity.roleRef !== input.roleRef ||
|
|
83
|
+
identity.conversationRef !== input.conversationRef)
|
|
84
|
+
throw new Error("PRODUCT_DISCUSSION_ADMISSION_IDENTITY_MISMATCH");
|
|
85
|
+
const current = admissions.get(discussionRef);
|
|
86
|
+
if (current) {
|
|
87
|
+
if (current.roleRef !== identity.roleRef ||
|
|
88
|
+
current.conversationRef !== identity.conversationRef ||
|
|
89
|
+
current.conversationLocator !== identity.conversationLocator)
|
|
90
|
+
throw new Error("PRODUCT_DISCUSSION_ADMISSION_IDENTITY_MISMATCH");
|
|
91
|
+
if (generation < current.generation)
|
|
92
|
+
throw new Error("PRODUCT_DISCUSSION_ADMISSION_GENERATION_STALE");
|
|
93
|
+
if (generation === current.generation &&
|
|
94
|
+
current.contentInstanceId === contentInstanceId &&
|
|
95
|
+
(expiresAt.get(discussionRef) ?? 0) > now())
|
|
96
|
+
return current;
|
|
97
|
+
}
|
|
98
|
+
const admission = mint({
|
|
99
|
+
discussionRef,
|
|
100
|
+
identity,
|
|
101
|
+
generation,
|
|
102
|
+
contentInstanceId,
|
|
103
|
+
});
|
|
104
|
+
admissions.set(discussionRef, admission);
|
|
105
|
+
expiresAt.set(discussionRef, now() + ttlMs);
|
|
106
|
+
return admission;
|
|
107
|
+
};
|
|
108
|
+
const verify = (input) => {
|
|
109
|
+
const current = admissions.get(input.discussionRef);
|
|
110
|
+
return Boolean(current &&
|
|
111
|
+
(expiresAt.get(input.discussionRef) ?? 0) > now() &&
|
|
112
|
+
current.roleRef === input.roleRef &&
|
|
113
|
+
current.conversationRef === input.conversationRef &&
|
|
114
|
+
current.conversationLocator === input.conversationLocator &&
|
|
115
|
+
current.generation === input.generation &&
|
|
116
|
+
secretEqual(current.discussionAdmissionToken, input.discussionAdmissionToken));
|
|
117
|
+
};
|
|
118
|
+
return Object.freeze({
|
|
119
|
+
ensure,
|
|
120
|
+
verify,
|
|
121
|
+
get(discussionRef) {
|
|
122
|
+
return admissions.get(discussionRef);
|
|
123
|
+
},
|
|
124
|
+
revoke(discussionRef) {
|
|
125
|
+
expiresAt.delete(discussionRef);
|
|
126
|
+
return admissions.delete(discussionRef);
|
|
127
|
+
},
|
|
128
|
+
clear() {
|
|
129
|
+
admissions.clear();
|
|
130
|
+
expiresAt.clear();
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export async function assertProductDiscussionConversationPreTask(options) {
|
|
135
|
+
const taskIds = await options.listTaskIds();
|
|
136
|
+
for (const taskId of taskIds) {
|
|
137
|
+
const bindings = await options.getTaskRoleBindings(taskId);
|
|
138
|
+
if (bindings.some((binding) => binding.conversationLocator === options.conversationLocator))
|
|
139
|
+
throw new Error("PRODUCT_DISCUSSION_TASK_WORKER_FORBIDDEN");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function createProductDiscussionAdmissionCoordinator(options) {
|
|
143
|
+
if (!productRoleRef.test(options.productRoleRef))
|
|
144
|
+
throw new Error("PRODUCT_DISCUSSION_ROLE_INVALID");
|
|
145
|
+
const observe = async (input) => {
|
|
146
|
+
const contentInstanceId = requiredText(input.contentInstanceId, "PRODUCT_DISCUSSION_CONTENT_INSTANCE_REQUIRED");
|
|
147
|
+
const identity = parseProductDiscussionConversationLocator(input.conversationLocator);
|
|
148
|
+
if (!identity)
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
admitted: false,
|
|
151
|
+
reason: "NOT_CUSTOM_GPT_CONVERSATION",
|
|
152
|
+
});
|
|
153
|
+
if (identity.roleRef !== options.productRoleRef)
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
admitted: false,
|
|
156
|
+
reason: "NOT_PRODUCT_ROLE",
|
|
157
|
+
});
|
|
158
|
+
if (options.isTaskWorkerProvisioning(identity.roleRef))
|
|
159
|
+
throw new Error("PRODUCT_DISCUSSION_TASK_WORKER_PROVISIONING");
|
|
160
|
+
await assertProductDiscussionConversationPreTask({
|
|
161
|
+
conversationLocator: identity.conversationLocator,
|
|
162
|
+
listTaskIds: options.listTaskIds,
|
|
163
|
+
getTaskRoleBindings: options.getTaskRoleBindings,
|
|
164
|
+
});
|
|
165
|
+
const discussionRef = productDiscussionRef(identity);
|
|
166
|
+
const session = await options.registerDiscussionSession({
|
|
167
|
+
discussionRef,
|
|
168
|
+
...identity,
|
|
169
|
+
});
|
|
170
|
+
if (session.status !== "ACTIVE" ||
|
|
171
|
+
session.discussionRef !== discussionRef ||
|
|
172
|
+
session.roleRef !== identity.roleRef ||
|
|
173
|
+
session.conversationRef !== identity.conversationRef ||
|
|
174
|
+
session.conversationLocator !== identity.conversationLocator)
|
|
175
|
+
throw new Error("PRODUCT_DISCUSSION_SESSION_IDENTITY_MISMATCH");
|
|
176
|
+
const admission = options.authority.ensure({
|
|
177
|
+
discussionRef,
|
|
178
|
+
...identity,
|
|
179
|
+
generation: session.generation,
|
|
180
|
+
contentInstanceId,
|
|
181
|
+
});
|
|
182
|
+
await options.deliverBootstrap(admission);
|
|
183
|
+
return Object.freeze({
|
|
184
|
+
admitted: true,
|
|
185
|
+
discussionRef,
|
|
186
|
+
discussionGeneration: session.generation,
|
|
187
|
+
bootstrapRef: admission.bootstrapRef,
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
return Object.freeze({ observe });
|
|
191
|
+
}
|