@tomflow/proflow-platform-host 0.1.30 → 0.1.32

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.
@@ -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
+ }
@@ -0,0 +1,43 @@
1
+ import type { ExecuteCapabilityRequest } from "@tomflow/proflow-execution-contracts";
2
+ import { type ProductDiscussionAdmissionVerification, type ProductDiscussionSessionOwnerFact, type ProductDiscussionTaskRoleBindingFact } from "./product-discussion-admission.ts";
3
+ export type ProductDiscussionHostTaskFact = Readonly<{
4
+ taskId: string;
5
+ status: string;
6
+ roleBindings: readonly ProductDiscussionTaskRoleBindingFact[];
7
+ }>;
8
+ export declare function createProductDiscussionHost(options: {
9
+ productRoleRef: string;
10
+ listTaskIds(): readonly string[] | Promise<readonly string[]>;
11
+ getTaskFact(taskId: string): ProductDiscussionHostTaskFact | Promise<ProductDiscussionHostTaskFact>;
12
+ registerDiscussionSession(input: {
13
+ discussionRef: string;
14
+ roleRef: string;
15
+ conversationRef: string;
16
+ conversationLocator: string;
17
+ }): ProductDiscussionSessionOwnerFact | Promise<ProductDiscussionSessionOwnerFact>;
18
+ getDiscussionSession(discussionRef: string): ProductDiscussionSessionOwnerFact | Promise<ProductDiscussionSessionOwnerFact>;
19
+ executeCapability(request: ExecuteCapabilityRequest): Promise<unknown>;
20
+ }): Readonly<{
21
+ recoverReservations: () => Promise<void>;
22
+ reserveTaskWorker: (taskId: string, roleRef: string) => void;
23
+ releaseTaskWorker: (taskId: string, roleRef: string) => void;
24
+ isTaskWorkerProvisioning: (roleRef: string) => boolean;
25
+ observe: (input: {
26
+ conversationLocator: string;
27
+ contentInstanceId: string;
28
+ }) => Promise<Readonly<{
29
+ admitted: false;
30
+ reason: "NOT_CUSTOM_GPT_CONVERSATION";
31
+ }> | Readonly<{
32
+ admitted: false;
33
+ reason: "NOT_PRODUCT_ROLE";
34
+ }> | Readonly<{
35
+ admitted: true;
36
+ discussionRef: string;
37
+ discussionGeneration: number;
38
+ bootstrapRef: string;
39
+ }>>;
40
+ authorizeBootstrapExecution: (request: ExecuteCapabilityRequest) => Promise<boolean>;
41
+ verifyAdmission(input: ProductDiscussionAdmissionVerification): boolean;
42
+ clear(): void;
43
+ }>;
@@ -0,0 +1,167 @@
1
+ import { assertProductDiscussionConversationPreTask, createProductDiscussionAdmissionAuthority, createProductDiscussionAdmissionCoordinator, parseProductDiscussionConversationLocator, productDiscussionRef, } from "./product-discussion-admission.js";
2
+ const reservableTaskStatuses = new Set(["PENDING", "READY", "ACTIVE", "WAITING"]);
3
+ function object(value, code) {
4
+ if (typeof value !== "object" || value === null || Array.isArray(value))
5
+ throw new Error(code);
6
+ return value;
7
+ }
8
+ export function createProductDiscussionHost(options) {
9
+ const authority = createProductDiscussionAdmissionAuthority();
10
+ const provisioning = new Map();
11
+ let recovered = false;
12
+ const reserveTaskWorker = (taskId, roleRef) => {
13
+ if (roleRef !== options.productRoleRef)
14
+ return;
15
+ const current = provisioning.get(roleRef) ?? new Set();
16
+ current.add(taskId);
17
+ provisioning.set(roleRef, current);
18
+ };
19
+ const releaseTaskWorker = (taskId, roleRef) => {
20
+ if (roleRef !== options.productRoleRef)
21
+ return;
22
+ const current = provisioning.get(roleRef);
23
+ if (!current)
24
+ return;
25
+ current.delete(taskId);
26
+ if (current.size === 0)
27
+ provisioning.delete(roleRef);
28
+ };
29
+ const isTaskWorkerProvisioning = (roleRef) => roleRef === options.productRoleRef &&
30
+ (!recovered || (provisioning.get(roleRef)?.size ?? 0) > 0);
31
+ const coordinator = createProductDiscussionAdmissionCoordinator({
32
+ productRoleRef: options.productRoleRef,
33
+ authority,
34
+ isTaskWorkerProvisioning,
35
+ listTaskIds: options.listTaskIds,
36
+ async getTaskRoleBindings(taskId) {
37
+ return (await options.getTaskFact(taskId)).roleBindings;
38
+ },
39
+ registerDiscussionSession: options.registerDiscussionSession,
40
+ async deliverBootstrap(admission) {
41
+ const request = {
42
+ contract: "execution",
43
+ contractVersion: "1.0.0",
44
+ idempotencyKey: admission.bootstrapRef,
45
+ callerRef: "platform-host:product-discussion-admission",
46
+ correlationId: admission.bootstrapRef,
47
+ roleRef: admission.roleRef,
48
+ capability: "product.discussion.bootstrap.deliver",
49
+ input: {
50
+ bootstrapRef: admission.bootstrapRef,
51
+ discussionRef: admission.discussionRef,
52
+ discussionGeneration: admission.generation,
53
+ discussionAdmissionToken: admission.discussionAdmissionToken,
54
+ roleRef: admission.roleRef,
55
+ conversationRef: admission.conversationRef,
56
+ conversationLocator: admission.conversationLocator,
57
+ contentInstanceId: admission.contentInstanceId,
58
+ contentFingerprint: admission.bootstrapRef,
59
+ },
60
+ };
61
+ const execution = object(await options.executeCapability(request), "PRODUCT_DISCUSSION_BOOTSTRAP_EXECUTION_INVALID");
62
+ const result = execution.result === undefined
63
+ ? undefined
64
+ : object(execution.result, "PRODUCT_DISCUSSION_BOOTSTRAP_RESULT_INVALID");
65
+ const data = result?.data === undefined
66
+ ? undefined
67
+ : object(result.data, "PRODUCT_DISCUSSION_BOOTSTRAP_RESULT_DATA_INVALID");
68
+ if (execution.status !== "SUCCEEDED" ||
69
+ execution.sideEffectState !== "APPLIED" ||
70
+ result?.capability !== "product.discussion.bootstrap.deliver" ||
71
+ data?.bootstrapRef !== admission.bootstrapRef ||
72
+ data?.delivered !== true ||
73
+ typeof data.evidenceRef !== "string")
74
+ throw new Error(`PRODUCT_DISCUSSION_BOOTSTRAP_NOT_CONFIRMED:${String(execution.status)}:${String(execution.sideEffectState)}`);
75
+ },
76
+ });
77
+ const recoverReservations = async () => {
78
+ if (recovered)
79
+ return;
80
+ const taskIds = await options.listTaskIds();
81
+ for (const taskId of taskIds) {
82
+ const task = await options.getTaskFact(taskId);
83
+ if (!reservableTaskStatuses.has(task.status))
84
+ continue;
85
+ for (const binding of task.roleBindings)
86
+ if (binding.roleRef === options.productRoleRef &&
87
+ (!binding.workerRef || !binding.conversationLocator))
88
+ reserveTaskWorker(task.taskId, binding.roleRef);
89
+ }
90
+ recovered = true;
91
+ };
92
+ const authorizeBootstrapExecution = async (request) => {
93
+ if (request.capability !== "product.discussion.bootstrap.deliver")
94
+ return false;
95
+ try {
96
+ if (!recovered)
97
+ return false;
98
+ if (request.callerRef !== "platform-host:product-discussion-admission")
99
+ return false;
100
+ if (request.taskId !== undefined ||
101
+ request.nodeId !== undefined ||
102
+ request.runNo !== undefined ||
103
+ request.workerRef !== undefined ||
104
+ request.projectRoot !== undefined)
105
+ return false;
106
+ const input = request.input;
107
+ if (request.roleRef !== options.productRoleRef ||
108
+ input.roleRef !== options.productRoleRef ||
109
+ isTaskWorkerProvisioning(input.roleRef) ||
110
+ input.contentFingerprint !== input.bootstrapRef)
111
+ return false;
112
+ const identity = parseProductDiscussionConversationLocator(input.conversationLocator);
113
+ if (!identity ||
114
+ identity.roleRef !== input.roleRef ||
115
+ identity.conversationRef !== input.conversationRef ||
116
+ productDiscussionRef(identity) !== input.discussionRef)
117
+ return false;
118
+ await assertProductDiscussionConversationPreTask({
119
+ conversationLocator: input.conversationLocator,
120
+ listTaskIds: options.listTaskIds,
121
+ async getTaskRoleBindings(taskId) {
122
+ return (await options.getTaskFact(taskId)).roleBindings;
123
+ },
124
+ });
125
+ const session = await options.getDiscussionSession(input.discussionRef);
126
+ if (session.status !== "ACTIVE" ||
127
+ session.discussionRef !== input.discussionRef ||
128
+ session.roleRef !== input.roleRef ||
129
+ session.conversationRef !== input.conversationRef ||
130
+ session.conversationLocator !== input.conversationLocator ||
131
+ session.generation !== input.discussionGeneration)
132
+ return false;
133
+ const current = authority.get(input.discussionRef);
134
+ if (!current ||
135
+ current.bootstrapRef !== input.bootstrapRef ||
136
+ current.contentInstanceId !== input.contentInstanceId)
137
+ return false;
138
+ return authority.verify({
139
+ discussionRef: input.discussionRef,
140
+ roleRef: input.roleRef,
141
+ conversationRef: input.conversationRef,
142
+ conversationLocator: input.conversationLocator,
143
+ generation: input.discussionGeneration,
144
+ discussionAdmissionToken: input.discussionAdmissionToken,
145
+ });
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ };
151
+ return Object.freeze({
152
+ recoverReservations,
153
+ reserveTaskWorker,
154
+ releaseTaskWorker,
155
+ isTaskWorkerProvisioning,
156
+ observe: coordinator.observe,
157
+ authorizeBootstrapExecution,
158
+ verifyAdmission(input) {
159
+ return recovered && !isTaskWorkerProvisioning(input.roleRef) && authority.verify(input);
160
+ },
161
+ clear() {
162
+ authority.clear();
163
+ provisioning.clear();
164
+ recovered = false;
165
+ },
166
+ });
167
+ }
@@ -33,5 +33,6 @@ export declare function createReconciliationCoordinator(options: ReconciliationC
33
33
  kick(taskId: string, signal?: TaskResumeSignal): void;
34
34
  reconcile: (taskId: string, signal?: TaskResumeSignal) => Promise<void>;
35
35
  sweep: () => Promise<void>;
36
+ drain(): Promise<void>;
36
37
  stop(): void;
37
38
  }>;
@@ -197,8 +197,7 @@ export function createReconciliationCoordinator(options) {
197
197
  }
198
198
  })().finally(() => {
199
199
  taskInFlight.delete(taskId);
200
- if (!stopped &&
201
- (pendingSignals.has(taskId) || failures.has(taskId)))
200
+ if (!stopped && (pendingSignals.has(taskId) || failures.has(taskId)))
202
201
  schedulePendingRetry(taskId);
203
202
  else
204
203
  admittedTasks.delete(taskId);
@@ -318,6 +317,12 @@ export function createReconciliationCoordinator(options) {
318
317
  },
319
318
  reconcile,
320
319
  sweep,
320
+ async drain() {
321
+ await Promise.allSettled([
322
+ ...(sweepInFlight ? [sweepInFlight] : []),
323
+ ...taskInFlight.values(),
324
+ ]);
325
+ },
321
326
  stop() {
322
327
  if (stopped)
323
328
  return;
@@ -42,6 +42,14 @@ export const roleOperations = {
42
42
  "getTask",
43
43
  "putTaskDocument",
44
44
  "getTaskDocument",
45
+ "getProductCampaign",
46
+ "getProductDiscussionContext",
47
+ "getProductDocument",
48
+ "putProductDocument",
49
+ "getCampaignAuthorization",
50
+ "submitProductTaskIntent",
51
+ "recordProductGapReview",
52
+ "recordProductGoalSatisfied",
45
53
  "askPeer",
46
54
  "replyPeer",
47
55
  ...directToolActionIds,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomflow/proflow-platform-host",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -24,19 +24,19 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "zod": "4.1.12",
27
- "@tomflow/proflow-execution-browser-extension": "^0.1.62",
28
- "@tomflow/proflow-task-migration-runner": "^0.1.11",
29
- "@tomflow/proflow-task-orchestration": "^0.1.11",
30
- "@tomflow/proflow-task-store-sqlite": "^0.1.12",
31
- "@tomflow/proflow-agent-runtime": "^0.1.17",
27
+ "@tomflow/proflow-agent-runtime": "^0.1.18",
32
28
  "@tomflow/proflow-module-contract": "^0.1.13",
33
- "@tomflow/proflow-execution-contracts": "^0.1.11"
29
+ "@tomflow/proflow-task-migration-runner": "^0.1.11",
30
+ "@tomflow/proflow-execution-browser-extension": "^0.1.74",
31
+ "@tomflow/proflow-execution-contracts": "^0.1.12",
32
+ "@tomflow/proflow-task-orchestration": "^0.1.12",
33
+ "@tomflow/proflow-task-store-sqlite": "^0.1.12"
34
34
  },
35
35
  "devDependencies": {
36
- "@tomflow/proflow-deployment-conformance": "^0.1.13",
37
- "@tomflow/proflow-execution-runtime": "^0.1.19",
38
- "@tomflow/proflow-model-runtime": "^0.1.25",
39
- "@tomflow/proflow-agent-gateway": "^0.1.18"
36
+ "@tomflow/proflow-agent-gateway": "^0.1.19",
37
+ "@tomflow/proflow-execution-runtime": "^0.1.20",
38
+ "@tomflow/proflow-model-runtime": "^0.1.26",
39
+ "@tomflow/proflow-deployment-conformance": "^0.1.13"
40
40
  },
41
41
  "description": "Provides the ProFlow local application composition root that binds Task, Agent, Execution and Model owner transports.",
42
42
  "keywords": [
@@ -3,7 +3,7 @@
3
3
  "contractVersion": "1.0.0",
4
4
  "moduleRef": "platform-host",
5
5
  "packageName": "@tomflow/proflow-platform-host",
6
- "moduleVersion": "0.1.30",
6
+ "moduleVersion": "0.1.32",
7
7
  "kind": "service",
8
8
  "templateVersion": "1.0.0",
9
9
  "platformCompatibility": ">=1.0.0 <2.0.0",