@blocks-network/sdk 0.1.64 → 0.1.65
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/index.d.ts +1 -1
- package/dist/runtime/task-client.d.ts +7 -0
- package/dist/runtime/task-client.js +1 -1
- package/dist/runtime/task-session.d.ts +27 -1
- package/dist/runtime/task-session.js +1 -1
- package/dist/runtime/terminal-delivery-tracker.d.ts +40 -0
- package/dist/runtime/terminal-delivery-tracker.js +1 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export { TaskClient, AnonTaskAccessDeniedError } from './runtime/task-client.js'
|
|
|
15
15
|
export type { TaskClientOptions, SendMessageParams, SendMessageRequestPart, TaskInfo, ListTasksParams, ListTasksResult, TaskEvent as TaskClientEvent, TaskEventCallbacks, TaskSubscription, } from './runtime/task-client.js';
|
|
16
16
|
export { RpcError, BillingModeMismatchError } from './runtime/rpc-client.js';
|
|
17
17
|
export { TaskSession } from './runtime/task-session.js';
|
|
18
|
-
export type { TaskEvent, ProgressEvent, ArtifactEvent, TerminalEvent, Unsubscribe, CallbackErrorContext, } from './runtime/task-session.js';
|
|
18
|
+
export type { TaskEvent, ProgressEvent, ArtifactEvent, TerminalEvent, CancelRequestedEvent, Unsubscribe, CallbackErrorContext, } from './runtime/task-session.js';
|
|
19
19
|
export { textPart, filePart, filePartFromPath } from './runtime/part-helpers.js';
|
|
20
20
|
export { StreamRef, StreamUnavailableError } from './runtime/stream-ref.js';
|
|
21
21
|
export type { StreamDescriptor } from './runtime/stream-ref.js';
|
|
@@ -147,6 +147,13 @@ export interface TaskEventCallbacks {
|
|
|
147
147
|
onArtifact?: (event: TaskEvent) => void;
|
|
148
148
|
/** Terminal events (type: "terminal") -- task completed, failed, or canceled */
|
|
149
149
|
onTerminal?: (event: TaskEvent) => void;
|
|
150
|
+
/**
|
|
151
|
+
* Backend acknowledgment that a cooperative cancel was committed
|
|
152
|
+
* (type: "cancel_requested"). Suppressed once a terminal has been
|
|
153
|
+
* delivered; suppressed on duplicate emissions of the same event.
|
|
154
|
+
* Mirrors Python's `on_cancel_requested`.
|
|
155
|
+
*/
|
|
156
|
+
onCancelRequested?: (event: TaskEvent) => void;
|
|
150
157
|
/** System events (type: "system") -- paused, resumed, etc. */
|
|
151
158
|
onSystem?: (event: TaskEvent) => void;
|
|
152
159
|
/** Catch-all for any event */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"pubnub";import{buildPubNubLogConfig as t}from"./pubnub-client.js";import{callRpc as n}from"./rpc-client.js";import{taskChannel as s}from"./channel-manager.js";import{TaskSession as i}from"./task-session.js";import{preflightAuthOrThrow as r}from"./auth-provider.js";import{ConsumerAuth as o}from"./consumer-auth.js";import{shouldInlineArtifact as a,buildArtifactRef as c}from"./artifacts.js";import{uploadFile as u}from"./file-upload.js";import{normalizeFileInput as d}from"./file-input.js";import{fetchCdmConfig as l}from"./cdm-config.js";import{captureAffinity as b,injectAffinity as h}from"./write-affinity.js";import{getEnv as p}from"../env.js";import{log as f}from"./logger.js";import{StreamRef as m}from"./stream-ref.js";import{invertDirection as g}from"../stream/index.js";import{asPubNubFetcher as k}from"./pubnub-types.js";import{CURRENT_PROTOCOL_VERSION as y,PROTOCOL_VERSION_HEADER as w}from"./protocol-version.js";import{getAgent as T}from"./agent-registry.js";const I=(e,t,n)=>f("[TaskClient]",e,t,n),S=new Set(["completed","failed","canceled"]);export class AnonTaskAccessDeniedError extends Error{constructor(e="anon-task-read-token denied: HTTP 403 Forbidden"){super(e),this.name="AnonTaskAccessDeniedError"}}function C(e,t,n,s){const i=new Map,r=[],o=[];let a,c="0",u="0";for(const d of e){d.timetoken&&d.timetoken>c&&(c=d.timetoken);const e=d.message;if(e&&"object"==typeof e&&e.type){if(o.push(e),"terminal"===e.type){const t=String(d.timetoken??"0");if(t>=u){const n=e.state;"completed"!==n&&"failed"!==n&&"canceled"!==n||(a=n,u=t)}}if("progress"===e.type&&"stream_started"===e.streamEvent&&e.streams){const r=e.declaredStream,o=e.streams;for(const[e,a]of Object.entries(o)){if(!a||"object"!=typeof a||i.has(e))continue;const o=a.direction,c=g(o),u=a.format;if(!u||"bytes"!==u&&"events"!==u)continue;const d=a.affinity;if("dedicated"!==d&&"shared"!==d){I("warn",`history-preload: dropping stream "${e}" for task "${t}" — invalid or missing affinity`,{event:"history_preload_invalid_affinity",streamId:e,taskId:t,receivedAffinity:a.affinity});continue}const l={taskId:t,streamId:e,agentName:n,channel:a.channel,token:a.token,agentDirection:o,localDirection:c,format:u,affinity:d,metadata:a.metadata,declaredStream:r};i.set(e,new m(l,s))}}if("artifact"===e.type&&e.artifactRef){const t=e.artifactRef;t&&"object"==typeof t&&t.kind&&r.push(t)}}}return{streams:i,artifacts:r,events:o,highWaterMark:c,terminalState:a}}async function P(e,t){const n=[];let s;for(;;){const i={channels:[t],count:100};s&&(i.start=s);const r=await e.fetchMessages(i),o=r.channels?.[t]??[];if(0===o.length)break;if(n.push(...o),o.length<100)break;s=o[0].timetoken}return n.sort((e,t)=>e.timetoken<t.timetoken?-1:e.timetoken>t.timetoken?1:0),n}export class TaskClient{constructor(e){if(this._ownsPubNub=!1,"free"!==e.billingMode&&"paid"!==e.billingMode)throw new Error("TaskClient requires a billingMode option ('free' or 'paid')");this._billingMode=e.billingMode,this._anonFingerprint=e.anonFingerprint??null,this.config={subscribeKey:e.subscribeKey,authProvider:e.authProvider,baseUrl:e.baseUrl,agentAuth:e.agentAuth},this._pubnub=e.pubnub,this._createPubNub=e.createPubNub,this._createSessionPubNub=e.createSessionPubNub,this.defaultOwnerId=e.defaultOwnerId,this._subscribeKey=e.subscribeKey,this._publishKey=e.publishKey??"",this._ownsPubNub=!e.pubnub&&!!e.createPubNub}static async create(n){const s=n??{},i=s.billingMode;if("free"!==i&&"paid"!==i)throw new Error("TaskClient.create() requires a billingMode option ('free' or 'paid')");const r=!!(s.apiKey||s.tokenEndpoint||s.tokenProvider),a=!!s.anonFingerprint;if([s.apiKey,s.tokenEndpoint,s.tokenProvider,s.anonFingerprint].filter(Boolean).length>1)throw new Error("Only one token provider mode may be specified");if(a&&"free"!==i)throw new Error("TaskClient.create() with anonFingerprint requires billingMode: 'free'");const c=s.cdmUrl??p("BLOCKS_CDM_URL"),u=await l(c),d="paid"===i?u.network:u.playground,b=s.subscribeKey??p("BLOCKS_SUBSCRIBE_KEY")??d.subscribeKey,h=s.publishKey??p("BLOCKS_PUBLISH_KEY")??d.publishKey,f=s.baseUrl??p("BLOCKS_BACKEND_URL")??u.api.baseUrl;if(!b)throw new Error("TaskClient.create() could not resolve subscribeKey from options, env, or CDM");if(!f)throw new Error("TaskClient.create() could not resolve baseUrl. Set baseUrl option, BLOCKS_BACKEND_URL env var, or ensure CDM config has api.baseUrl.");const m=()=>{const n=`blocks-task-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`;return new e({subscribeKey:b,publishKey:h||void 0,userId:n,enableEventEngine:!0,...t()})};if(r){const e=new o({apiKey:s.apiKey,tokenEndpoint:s.tokenEndpoint,tokenProvider:s.tokenProvider,baseUrl:f,onAuthError:s.onAuthError});await e.init();const t=new TaskClient({billingMode:i,subscribeKey:b,publishKey:h,baseUrl:f,createSessionPubNub:m,defaultOwnerId:e.getUserId()??void 0});return t.config.authProvider=e,t._consumerAuth=e,t}return new TaskClient({billingMode:i,subscribeKey:b,publishKey:h,baseUrl:f,createSessionPubNub:m,anonFingerprint:s.anonFingerprint})}getUserId(){return this._consumerAuth?.getUserId()??null}async getAgentCard(e){const t=await T(e,{baseUrl:this.config.baseUrl});return t?.card??null}updateKeys(e,t){this.config.subscribeKey=e,this._subscribeKey=e,this._publishKey=t??""}getPubNub(){if(!this._pubnub&&this._createPubNub&&(this._pubnub=this._createPubNub()),!this._pubnub)throw new Error("TaskClient requires a pubnub instance for subscribe. Pass pubnub or createPubNub in TaskClientOptions.");return this._pubnub}destroy(){this._pubnub&&this._ownsPubNub&&(this._pubnub.destroy(),this._pubnub=void 0),this._consumerAuth&&this._consumerAuth.destroy()}[Symbol.dispose](){this.destroy()}createPerSessionPubNub(n,s,i){let r;const o=s??this._subscribeKey,a=i??this._publishKey;if(this._createSessionPubNub&&o===this._subscribeKey)r=this._createSessionPubNub();else{const n=`blocks-task-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`;r=new e({subscribeKey:o,publishKey:a||void 0,userId:n,enableEventEngine:!0,...t()})}return n&&r.setToken(n),r}async fetchConsumerReadToken(e,t="consumer"){if(!this.config.baseUrl)throw new Error("connect() requires a backend baseUrl. Set baseUrl in TaskClientOptions.");const n=`${this.config.baseUrl.replace(/\/+$/,"")}/api/v1/auth/task-read-token`,s=async()=>{const s={"Content-Type":"application/json",[w]:y},i=this.config.authProvider?.getAuthHeader();return i&&(s.Authorization=i),h(s),fetch(n,{method:"POST",headers:s,body:JSON.stringify({taskId:e,role:t})})};let i=await s();if(b(i.headers),401===i.status&&this.config.authProvider){await this.config.authProvider.onAuthFailure()&&(i=await s(),b(i.headers))}if(!i.ok){const e=await i.text().catch(()=>"");throw new Error(`task-read-token failed: HTTP ${i.status}${e?` ${e}`:""}`)}return i.json()}async fetchAnonConsumerReadToken(e){if(!this.config.baseUrl)throw new Error("connect() requires a backend baseUrl. Set baseUrl in TaskClientOptions.");if(!this._anonFingerprint)throw new Error("fetchAnonConsumerReadToken called without anonFingerprint");const t=`${this.config.baseUrl.replace(/\/+$/,"")}/api/v1/auth/anon-task-read-token`,n={"Content-Type":"application/json",[w]:y};h(n);const s=await fetch(t,{method:"POST",headers:n,body:JSON.stringify({taskId:e,fingerprint:this._anonFingerprint})});if(b(s.headers),403===s.status){const e=await s.text().catch(()=>"");throw new AnonTaskAccessDeniedError("anon-task-read-token denied: HTTP 403"+(e?` ${e}`:""))}if(!s.ok){const e=await s.text().catch(()=>"");throw new Error(`anon-task-read-token failed: HTTP ${s.status}${e?` ${e}`:""}`)}return s.json()}async sendMessage(e){if(this._anonFingerprint)throw new Error("anon-mode TaskClient does not support sendMessage()");await r(this.config.authProvider),this.config.authProvider?.ensureReady&&await this.config.authProvider.ensureReady();const t=e.ownerId||this.defaultOwnerId||"",o=e.taskKind,l=e.duration;if("pipe"===o){if(null==l||!Number.isInteger(l)||l<1||l>43200)throw new Error("Pipe tasks require an integer duration between 1 and 43200 minutes")}else if(null!=l)throw new Error("Request tasks must not include a duration. Duration is only valid for pipe tasks.");let b;const h=[];for(const t of e.requestParts)if(t.file){if(!t.partId)throw new Error("partId is required for file-bearing request parts");const n=d(t.file),s=t.fileName??"unnamed",i=t.contentType??"application/octet-stream";if(a(n.size)){const e=await n.getBytes(),r=c({data:e,mimeType:i,fileName:s}),{file:o,fileName:a,text:u,...d}=t;h.push({...d,artifactRef:r})}else{if(!this.config.baseUrl)throw new Error("File upload requires a backend baseUrl. Set baseUrl in TaskClientOptions.");const r={baseUrl:this.config.baseUrl,authProvider:this.config.authProvider,agentAuth:this.config.agentAuth},o={role:"consumer-input",agentName:e.agentName,fileName:s,fileSize:n.size,mimeType:i,partId:t.partId,uploadSessionId:b},a=await u(r,o,n.uploadBody);a.uploadSessionId&&(b=a.uploadSessionId);const{file:c,fileName:d,...l}=t;h.push({partId:l.partId,...l.contentType?{contentType:l.contentType}:{}})}}else{const{file:e,fileName:n,...s}=t;h.push(s)}const p={agentName:e.agentName,billingMode:this._billingMode,requestParts:h};b&&(p.uploadSessionId=b),e.idempotencyKey&&(p.idempotencyKey=e.idempotencyKey),p.ownerId=t;const f={};o&&(f.taskKind=o),null!=l&&(f.duration=l),e.consumerPublicKey&&(f.consumerPublicKey=e.consumerPublicKey),Object.keys(f).length>0&&(p.extensions={blocks:f}),e.pushNotificationConfig&&(p.pushNotificationConfig=e.pushNotificationConfig),e.retryPolicy&&(p.retryPolicy=e.retryPolicy);const m=await n(this.config,"SendMessage",p),g=m.extensions?.blocks,y=g?.readToken??null,w=g?.streamChannels?.status??void 0,T=g?.subscribeKey??this._subscribeKey,I=g?.publishKey??this._publishKey;if(!0===m.idempotent&&!!m.state&&S.has(m.state))return new i({taskId:m.taskId,ownerId:t,orgId:m.orgId??t,readToken:y,statusChannel:w,agentName:e.agentName,pubnub:null,ownsSubscribeClient:!1,sdkOptions:{subscribeKey:T,publishKey:I},rpcConfig:this.config,idempotent:m.idempotent,queued:m.queued,pushConfigId:m.pushConfigId,preClosed:!0,state:m.state});const K=this.createPerSessionPubNub(y,T,I),_={subscribeKey:T,publishKey:I},N=w??s(m.taskId,m.orgId??t);try{const n=await K.time(),s=String(n.timetoken),r=k(K);let o,a=new Map,c=[],u="0";if(r?.fetchMessages){const t=C(await P(r,N),m.taskId,e.agentName,_);a=t.streams,c=t.artifacts,u=t.highWaterMark,o=t.terminalState}if(o&&S.has(o))return new i({taskId:m.taskId,ownerId:t,orgId:m.orgId??t,readToken:y,statusChannel:N,agentName:e.agentName,pubnub:K,ownsSubscribeClient:!0,sdkOptions:_,rpcConfig:this.config,idempotent:m.idempotent,queued:m.queued,pushConfigId:m.pushConfigId,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs,state:o,skipSubscription:!0,preloadedStreams:a,preloadedArtifacts:c});const d="0"!==u?u:s,l=[];let b,h=!1;const p={message:e=>{if(e.channel!==N)return;const t=e.message;if(!t||"object"!=typeof t||!t.type)return;const n=String(e.timetoken??"0");h&&b?b(t,n):l.push({message:t,timetoken:n})}};K.addListener(p),K.subscribe({channels:[N],timetoken:d});const f=new i({taskId:m.taskId,ownerId:t,orgId:m.orgId??t,readToken:y,statusChannel:N,agentName:e.agentName,pubnub:K,ownsSubscribeClient:!0,sdkOptions:_,rpcConfig:this.config,idempotent:m.idempotent,queued:m.queued,pushConfigId:m.pushConfigId,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs,preloadedStreams:a,preloadedArtifacts:c,externalSubscription:{listener:p,channel:N,onReady:e=>{b=e}}});if(b){const e=b;for(const t of l)t.timetoken>d&&e(t.message,t.timetoken)}return l.length=0,h=!0,f}catch{return new i({taskId:m.taskId,ownerId:t,orgId:m.orgId??t,readToken:y,statusChannel:N,agentName:e.agentName,pubnub:K,ownsSubscribeClient:!0,sdkOptions:_,rpcConfig:this.config,idempotent:m.idempotent,queued:m.queued,pushConfigId:m.pushConfigId,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs})}}async connect(e){const{taskId:t}=e;if(this._anonFingerprint){const n=await this.getTask(t);if(!n)throw new Error(`Task not found: ${t}`);const s=n.agentName??"",i=n.state??"",r=await this.fetchAnonConsumerReadToken(t);return this._openSessionFromToken({task:n,agentName:s,taskState:i,tokenResult:r,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs})}if(await r(this.config.authProvider),!this.config.authProvider?.getAuthHeader())throw new Error("connect() requires an authenticated TaskClient. Use apiKey, tokenEndpoint, or tokenProvider. AgentAuth is not supported for consumer task connections.");const n=await this.getTask(t);if(!n)throw new Error(`Task not found: ${t}`);const s=n.agentName??"",i=n.state??"",o=await this.fetchConsumerReadToken(t,e.role);return this._openSessionFromToken({task:n,agentName:s,taskState:i,tokenResult:o,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs})}async _openSessionFromToken(e){const{task:t,agentName:n,taskState:s,tokenResult:r,autoDrain:o,drainWindowMs:a}=e,c=t.taskId,{pamToken:u,channel:d}=r,l={subscribeKey:this._subscribeKey,publishKey:this._publishKey},b=this.createPerSessionPubNub(u);try{const e=await b.time(),r=String(e.timetoken),h=k(b);let p,f=new Map,m=[],g=[],y="0";if(h?.fetchMessages){const e=C(await P(h,d),c,n,l);f=e.streams,m=e.artifacts,g=e.events,y=e.highWaterMark,p=e.terminalState}const w=p&&S.has(p)?p:s;if(S.has(w))return new i({taskId:c,ownerId:t.owner??"",readToken:u,statusChannel:d,agentName:n,pubnub:b,ownsSubscribeClient:!0,sdkOptions:l,rpcConfig:this.config,autoDrain:o,drainWindowMs:a,state:w,skipSubscription:!0,preloadedStreams:f,preloadedArtifacts:m,preloadedEvents:g});const T="0"!==y?y:r,I=[];let K,_=!1;const N={message:e=>{if(e.channel!==d)return;const t=e.message;if(!t||"object"!=typeof t||!t.type)return;const n=String(e.timetoken??"0");_&&K?K(t,n):I.push({message:t,timetoken:n})}};b.addListener(N),b.subscribe({channels:[d],timetoken:T});const v=new i({taskId:c,ownerId:t.owner??"",readToken:u,statusChannel:d,agentName:n,pubnub:b,ownsSubscribeClient:!0,sdkOptions:l,rpcConfig:this.config,autoDrain:o,drainWindowMs:a,state:s,preloadedStreams:f,preloadedArtifacts:m,preloadedEvents:g,externalSubscription:{listener:N,channel:d,onReady:e=>{K=e}}});if(K){const e=K;for(const t of I)t.timetoken>T&&(v._appendHistoryEvent(t.message,t.timetoken),e(t.message,t.timetoken))}return I.length=0,_=!0,v}catch(e){throw b.destroy(),e}}async getTask(e){return(await n(this.config,"GetTask",{taskId:e})).task}async listTasks(e){return n(this.config,"ListTasks",{...e})}async cancelTask(e){await n(this.config,"CancelTask",{taskId:e})}async pauseTask(e){await n(this.config,"PauseTask",{taskId:e})}async resumeTask(e){await n(this.config,"ResumeTask",{taskId:e})}async retryTask(e){await n(this.config,"RetryTask",{taskId:e})}async terminateTask(e){await n(this.config,"TerminateTask",{taskId:e})}subscribeToTask(e,t,n){return function(e,t,n,i){const r=s(t,n),o={message:e=>{if(e.channel!==r)return;const t=e.message;if(t&&"object"==typeof t&&t.type){if(i.onEvent)try{i.onEvent(t)}catch(e){K(e,"onEvent",t,i.onError)}switch(t.type){case"progress":if(i.onProgress)try{i.onProgress(t)}catch(e){K(e,"onProgress",t,i.onError)}break;case"artifact":if(i.onArtifact)try{i.onArtifact(t)}catch(e){K(e,"onArtifact",t,i.onError)}break;case"terminal":if(i.onTerminal)try{i.onTerminal(t)}catch(e){K(e,"onTerminal",t,i.onError)}break;case"system":if(i.onSystem)try{i.onSystem(t)}catch(e){K(e,"onSystem",t,i.onError)}}}}};return e.addListener(o),e.subscribe({channels:[r],timetoken:1e3}),{unsubscribe(){e.removeListener(o),e.unsubscribe({channels:[r]})}}}(this.getPubNub(),e,t,n)}}function K(e,t,n,s){const i=e instanceof Error?e:new Error(String(e));if(s)try{s(i,{entryPoint:"subscribeToTask",callbackType:t,event:n})}catch{}else I("warn",`subscribeToTask callback error in ${t}`,{event:"subscribe_callback_error",callbackType:t,error:i.message})}
|
|
1
|
+
import e from"pubnub";import{buildPubNubLogConfig as t}from"./pubnub-client.js";import{callRpc as n}from"./rpc-client.js";import{taskChannel as s}from"./channel-manager.js";import{TaskSession as i}from"./task-session.js";import{TerminalDeliveryTracker as r}from"./terminal-delivery-tracker.js";import{preflightAuthOrThrow as o}from"./auth-provider.js";import{ConsumerAuth as a}from"./consumer-auth.js";import{shouldInlineArtifact as c,buildArtifactRef as u}from"./artifacts.js";import{uploadFile as d}from"./file-upload.js";import{normalizeFileInput as l}from"./file-input.js";import{fetchCdmConfig as b}from"./cdm-config.js";import{captureAffinity as h,injectAffinity as f}from"./write-affinity.js";import{getEnv as p}from"../env.js";import{log as m}from"./logger.js";import{StreamRef as g}from"./stream-ref.js";import{invertDirection as k}from"../stream/index.js";import{asPubNubFetcher as y}from"./pubnub-types.js";import{CURRENT_PROTOCOL_VERSION as w,PROTOCOL_VERSION_HEADER as T}from"./protocol-version.js";import{getAgent as I}from"./agent-registry.js";const S=(e,t,n)=>m("[TaskClient]",e,t,n),C=new Set(["completed","failed","canceled"]);export class AnonTaskAccessDeniedError extends Error{constructor(e="anon-task-read-token denied: HTTP 403 Forbidden"){super(e),this.name="AnonTaskAccessDeniedError"}}function P(e,t,n,s){const i=new Map,r=[],o=[];let a,c="0",u="0";for(const d of e){d.timetoken&&d.timetoken>c&&(c=d.timetoken);const e=d.message;if(e&&"object"==typeof e&&e.type){if(o.push(e),"terminal"===e.type){const t=String(d.timetoken??"0");if(t>=u){const n=e.state;"completed"!==n&&"failed"!==n&&"canceled"!==n||(a=n,u=t)}}if("progress"===e.type&&"stream_started"===e.streamEvent&&e.streams){const r=e.declaredStream,o=e.streams;for(const[e,a]of Object.entries(o)){if(!a||"object"!=typeof a||i.has(e))continue;const o=a.direction,c=k(o),u=a.format;if(!u||"bytes"!==u&&"events"!==u)continue;const d=a.affinity;if("dedicated"!==d&&"shared"!==d){S("warn",`history-preload: dropping stream "${e}" for task "${t}" — invalid or missing affinity`,{event:"history_preload_invalid_affinity",streamId:e,taskId:t,receivedAffinity:a.affinity});continue}const l={taskId:t,streamId:e,agentName:n,channel:a.channel,token:a.token,agentDirection:o,localDirection:c,format:u,affinity:d,metadata:a.metadata,declaredStream:r};i.set(e,new g(l,s))}}if("artifact"===e.type&&e.artifactRef){const t=e.artifactRef;t&&"object"==typeof t&&t.kind&&r.push(t)}}}return{streams:i,artifacts:r,events:o,highWaterMark:c,terminalState:a}}async function K(e,t){const n=[];let s;for(;;){const i={channels:[t],count:100};s&&(i.start=s);const r=await e.fetchMessages(i),o=r.channels?.[t]??[];if(0===o.length)break;if(n.push(...o),o.length<100)break;s=o[0].timetoken}return n.sort((e,t)=>e.timetoken<t.timetoken?-1:e.timetoken>t.timetoken?1:0),n}export class TaskClient{constructor(e){if(this._ownsPubNub=!1,"free"!==e.billingMode&&"paid"!==e.billingMode)throw new Error("TaskClient requires a billingMode option ('free' or 'paid')");this._billingMode=e.billingMode,this._anonFingerprint=e.anonFingerprint??null,this.config={subscribeKey:e.subscribeKey,authProvider:e.authProvider,baseUrl:e.baseUrl,agentAuth:e.agentAuth},this._pubnub=e.pubnub,this._createPubNub=e.createPubNub,this._createSessionPubNub=e.createSessionPubNub,this.defaultOwnerId=e.defaultOwnerId,this._subscribeKey=e.subscribeKey,this._publishKey=e.publishKey??"",this._ownsPubNub=!e.pubnub&&!!e.createPubNub}static async create(n){const s=n??{},i=s.billingMode;if("free"!==i&&"paid"!==i)throw new Error("TaskClient.create() requires a billingMode option ('free' or 'paid')");const r=!!(s.apiKey||s.tokenEndpoint||s.tokenProvider),o=!!s.anonFingerprint;if([s.apiKey,s.tokenEndpoint,s.tokenProvider,s.anonFingerprint].filter(Boolean).length>1)throw new Error("Only one token provider mode may be specified");if(o&&"free"!==i)throw new Error("TaskClient.create() with anonFingerprint requires billingMode: 'free'");const c=s.cdmUrl??p("BLOCKS_CDM_URL"),u=await b(c),d="paid"===i?u.network:u.playground,l=s.subscribeKey??p("BLOCKS_SUBSCRIBE_KEY")??d.subscribeKey,h=s.publishKey??p("BLOCKS_PUBLISH_KEY")??d.publishKey,f=s.baseUrl??p("BLOCKS_BACKEND_URL")??u.api.baseUrl;if(!l)throw new Error("TaskClient.create() could not resolve subscribeKey from options, env, or CDM");if(!f)throw new Error("TaskClient.create() could not resolve baseUrl. Set baseUrl option, BLOCKS_BACKEND_URL env var, or ensure CDM config has api.baseUrl.");const m=()=>{const n=`blocks-task-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`;return new e({subscribeKey:l,publishKey:h||void 0,userId:n,enableEventEngine:!0,...t()})};if(r){const e=new a({apiKey:s.apiKey,tokenEndpoint:s.tokenEndpoint,tokenProvider:s.tokenProvider,baseUrl:f,onAuthError:s.onAuthError});await e.init();const t=new TaskClient({billingMode:i,subscribeKey:l,publishKey:h,baseUrl:f,createSessionPubNub:m,defaultOwnerId:e.getUserId()??void 0});return t.config.authProvider=e,t._consumerAuth=e,t}return new TaskClient({billingMode:i,subscribeKey:l,publishKey:h,baseUrl:f,createSessionPubNub:m,anonFingerprint:s.anonFingerprint})}getUserId(){return this._consumerAuth?.getUserId()??null}async getAgentCard(e){const t=await I(e,{baseUrl:this.config.baseUrl});return t?.card??null}updateKeys(e,t){this.config.subscribeKey=e,this._subscribeKey=e,this._publishKey=t??""}getPubNub(){if(!this._pubnub&&this._createPubNub&&(this._pubnub=this._createPubNub()),!this._pubnub)throw new Error("TaskClient requires a pubnub instance for subscribe. Pass pubnub or createPubNub in TaskClientOptions.");return this._pubnub}destroy(){this._pubnub&&this._ownsPubNub&&(this._pubnub.destroy(),this._pubnub=void 0),this._consumerAuth&&this._consumerAuth.destroy()}[Symbol.dispose](){this.destroy()}createPerSessionPubNub(n,s,i){let r;const o=s??this._subscribeKey,a=i??this._publishKey;if(this._createSessionPubNub&&o===this._subscribeKey)r=this._createSessionPubNub();else{const n=`blocks-task-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`;r=new e({subscribeKey:o,publishKey:a||void 0,userId:n,enableEventEngine:!0,...t()})}return n&&r.setToken(n),r}async fetchConsumerReadToken(e,t="consumer"){if(!this.config.baseUrl)throw new Error("connect() requires a backend baseUrl. Set baseUrl in TaskClientOptions.");const n=`${this.config.baseUrl.replace(/\/+$/,"")}/api/v1/auth/task-read-token`,s=async()=>{const s={"Content-Type":"application/json",[T]:w},i=this.config.authProvider?.getAuthHeader();return i&&(s.Authorization=i),f(s),fetch(n,{method:"POST",headers:s,body:JSON.stringify({taskId:e,role:t})})};let i=await s();if(h(i.headers),401===i.status&&this.config.authProvider){await this.config.authProvider.onAuthFailure()&&(i=await s(),h(i.headers))}if(!i.ok){const e=await i.text().catch(()=>"");throw new Error(`task-read-token failed: HTTP ${i.status}${e?` ${e}`:""}`)}return i.json()}async fetchAnonConsumerReadToken(e){if(!this.config.baseUrl)throw new Error("connect() requires a backend baseUrl. Set baseUrl in TaskClientOptions.");if(!this._anonFingerprint)throw new Error("fetchAnonConsumerReadToken called without anonFingerprint");const t=`${this.config.baseUrl.replace(/\/+$/,"")}/api/v1/auth/anon-task-read-token`,n={"Content-Type":"application/json",[T]:w};f(n);const s=await fetch(t,{method:"POST",headers:n,body:JSON.stringify({taskId:e,fingerprint:this._anonFingerprint})});if(h(s.headers),403===s.status){const e=await s.text().catch(()=>"");throw new AnonTaskAccessDeniedError("anon-task-read-token denied: HTTP 403"+(e?` ${e}`:""))}if(!s.ok){const e=await s.text().catch(()=>"");throw new Error(`anon-task-read-token failed: HTTP ${s.status}${e?` ${e}`:""}`)}return s.json()}async sendMessage(e){if(this._anonFingerprint)throw new Error("anon-mode TaskClient does not support sendMessage()");await o(this.config.authProvider),this.config.authProvider?.ensureReady&&await this.config.authProvider.ensureReady();const t=e.ownerId||this.defaultOwnerId||"",r=e.taskKind,a=e.duration;if("pipe"===r){if(null==a||!Number.isInteger(a)||a<1||a>43200)throw new Error("Pipe tasks require an integer duration between 1 and 43200 minutes")}else if(null!=a)throw new Error("Request tasks must not include a duration. Duration is only valid for pipe tasks.");let b;const h=[];for(const t of e.requestParts)if(t.file){if(!t.partId)throw new Error("partId is required for file-bearing request parts");const n=l(t.file),s=t.fileName??"unnamed",i=t.contentType??"application/octet-stream";if(c(n.size)){const e=await n.getBytes(),r=u({data:e,mimeType:i,fileName:s}),{file:o,fileName:a,text:c,...d}=t;h.push({...d,artifactRef:r})}else{if(!this.config.baseUrl)throw new Error("File upload requires a backend baseUrl. Set baseUrl in TaskClientOptions.");const r={baseUrl:this.config.baseUrl,authProvider:this.config.authProvider,agentAuth:this.config.agentAuth},o={role:"consumer-input",agentName:e.agentName,fileName:s,fileSize:n.size,mimeType:i,partId:t.partId,uploadSessionId:b},a=await d(r,o,n.uploadBody);a.uploadSessionId&&(b=a.uploadSessionId);const{file:c,fileName:u,...l}=t;h.push({partId:l.partId,...l.contentType?{contentType:l.contentType}:{}})}}else{const{file:e,fileName:n,...s}=t;h.push(s)}const f={agentName:e.agentName,billingMode:this._billingMode,requestParts:h};b&&(f.uploadSessionId=b),e.idempotencyKey&&(f.idempotencyKey=e.idempotencyKey),f.ownerId=t;const p={};r&&(p.taskKind=r),null!=a&&(p.duration=a),e.consumerPublicKey&&(p.consumerPublicKey=e.consumerPublicKey),Object.keys(p).length>0&&(f.extensions={blocks:p}),e.pushNotificationConfig&&(f.pushNotificationConfig=e.pushNotificationConfig),e.retryPolicy&&(f.retryPolicy=e.retryPolicy);const m=await n(this.config,"SendMessage",f),g=m.extensions?.blocks,k=g?.readToken??null,w=g?.streamChannels?.status??void 0,T=g?.subscribeKey??this._subscribeKey,I=g?.publishKey??this._publishKey;if(!0===m.idempotent&&!!m.state&&C.has(m.state))return new i({taskId:m.taskId,ownerId:t,orgId:m.orgId??t,readToken:k,statusChannel:w,agentName:e.agentName,pubnub:null,ownsSubscribeClient:!1,sdkOptions:{subscribeKey:T,publishKey:I},rpcConfig:this.config,idempotent:m.idempotent,queued:m.queued,pushConfigId:m.pushConfigId,preClosed:!0,state:m.state});const S=this.createPerSessionPubNub(k,T,I),_={subscribeKey:T,publishKey:I},v=w??s(m.taskId,m.orgId??t);try{const n=await S.time(),s=String(n.timetoken),r=y(S);let o,a=new Map,c=[],u="0";if(r?.fetchMessages){const t=P(await K(r,v),m.taskId,e.agentName,_);a=t.streams,c=t.artifacts,u=t.highWaterMark,o=t.terminalState}if(o&&C.has(o))return new i({taskId:m.taskId,ownerId:t,orgId:m.orgId??t,readToken:k,statusChannel:v,agentName:e.agentName,pubnub:S,ownsSubscribeClient:!0,sdkOptions:_,rpcConfig:this.config,idempotent:m.idempotent,queued:m.queued,pushConfigId:m.pushConfigId,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs,state:o,skipSubscription:!0,preloadedStreams:a,preloadedArtifacts:c});const d="0"!==u?u:s,l=[];let b,h=!1;const f={message:e=>{if(e.channel!==v)return;const t=e.message;if(!t||"object"!=typeof t||!t.type)return;const n=String(e.timetoken??"0");h&&b?b(t,n):l.push({message:t,timetoken:n})}};S.addListener(f),S.subscribe({channels:[v],timetoken:d});const p=new i({taskId:m.taskId,ownerId:t,orgId:m.orgId??t,readToken:k,statusChannel:v,agentName:e.agentName,pubnub:S,ownsSubscribeClient:!0,sdkOptions:_,rpcConfig:this.config,idempotent:m.idempotent,queued:m.queued,pushConfigId:m.pushConfigId,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs,preloadedStreams:a,preloadedArtifacts:c,externalSubscription:{listener:f,channel:v,onReady:e=>{b=e}}});if(b){const e=b;for(const t of l)t.timetoken>d&&e(t.message,t.timetoken)}return l.length=0,h=!0,p}catch{return new i({taskId:m.taskId,ownerId:t,orgId:m.orgId??t,readToken:k,statusChannel:v,agentName:e.agentName,pubnub:S,ownsSubscribeClient:!0,sdkOptions:_,rpcConfig:this.config,idempotent:m.idempotent,queued:m.queued,pushConfigId:m.pushConfigId,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs})}}async connect(e){const{taskId:t}=e;if(this._anonFingerprint){const n=await this.getTask(t);if(!n)throw new Error(`Task not found: ${t}`);const s=n.agentName??"",i=n.state??"",r=await this.fetchAnonConsumerReadToken(t);return this._openSessionFromToken({task:n,agentName:s,taskState:i,tokenResult:r,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs})}if(await o(this.config.authProvider),!this.config.authProvider?.getAuthHeader())throw new Error("connect() requires an authenticated TaskClient. Use apiKey, tokenEndpoint, or tokenProvider. AgentAuth is not supported for consumer task connections.");const n=await this.getTask(t);if(!n)throw new Error(`Task not found: ${t}`);const s=n.agentName??"",i=n.state??"",r=await this.fetchConsumerReadToken(t,e.role);return this._openSessionFromToken({task:n,agentName:s,taskState:i,tokenResult:r,autoDrain:e.autoDrain,drainWindowMs:e.drainWindowMs})}async _openSessionFromToken(e){const{task:t,agentName:n,taskState:s,tokenResult:r,autoDrain:o,drainWindowMs:a}=e,c=t.taskId,{pamToken:u,channel:d}=r,l={subscribeKey:this._subscribeKey,publishKey:this._publishKey},b=this.createPerSessionPubNub(u);try{const e=await b.time(),r=String(e.timetoken),h=y(b);let f,p=new Map,m=[],g=[],k="0";if(h?.fetchMessages){const e=P(await K(h,d),c,n,l);p=e.streams,m=e.artifacts,g=e.events,k=e.highWaterMark,f=e.terminalState}const w=f&&C.has(f)?f:s;if(C.has(w))return new i({taskId:c,ownerId:t.owner??"",readToken:u,statusChannel:d,agentName:n,pubnub:b,ownsSubscribeClient:!0,sdkOptions:l,rpcConfig:this.config,autoDrain:o,drainWindowMs:a,state:w,skipSubscription:!0,preloadedStreams:p,preloadedArtifacts:m,preloadedEvents:g});const T="0"!==k?k:r,I=[];let S,_=!1;const v={message:e=>{if(e.channel!==d)return;const t=e.message;if(!t||"object"!=typeof t||!t.type)return;const n=String(e.timetoken??"0");_&&S?S(t,n):I.push({message:t,timetoken:n})}};b.addListener(v),b.subscribe({channels:[d],timetoken:T});const E=new i({taskId:c,ownerId:t.owner??"",readToken:u,statusChannel:d,agentName:n,pubnub:b,ownsSubscribeClient:!0,sdkOptions:l,rpcConfig:this.config,autoDrain:o,drainWindowMs:a,state:s,preloadedStreams:p,preloadedArtifacts:m,preloadedEvents:g,externalSubscription:{listener:v,channel:d,onReady:e=>{S=e}}});if(S){const e=S;for(const t of I)t.timetoken>T&&(E._appendHistoryEvent(t.message,t.timetoken),e(t.message,t.timetoken))}return I.length=0,_=!0,E}catch(e){throw b.destroy(),e}}async getTask(e){return(await n(this.config,"GetTask",{taskId:e})).task}async listTasks(e){return n(this.config,"ListTasks",{...e})}async cancelTask(e){await n(this.config,"CancelTask",{taskId:e})}async pauseTask(e){await n(this.config,"PauseTask",{taskId:e})}async resumeTask(e){await n(this.config,"ResumeTask",{taskId:e})}async retryTask(e){await n(this.config,"RetryTask",{taskId:e})}async terminateTask(e){await n(this.config,"TerminateTask",{taskId:e})}subscribeToTask(e,t,n){return function(e,t,n,i){const o=s(t,n),a=new r;let c=!1;const u={message:e=>{if(e.channel!==o)return;const t=e.message;if(t&&"object"==typeof t&&t.type){if(i.onEvent)try{i.onEvent(t)}catch(e){_(e,"onEvent",t,i.onError)}switch(t.type){case"progress":if(i.onProgress)try{i.onProgress(t)}catch(e){_(e,"onProgress",t,i.onError)}break;case"artifact":if(i.onArtifact)try{i.onArtifact(t)}catch(e){_(e,"onArtifact",t,i.onError)}break;case"terminal":a.tryDeliver(t,e=>{if(i.onTerminal)try{i.onTerminal(e)}catch(t){_(t,"onTerminal",e,i.onError)}});break;case"cancel_requested":if(a.isDelivered)break;if(c)break;if(c=!0,i.onCancelRequested)try{i.onCancelRequested(t)}catch(e){_(e,"onCancelRequested",t,i.onError)}break;case"system":if(i.onSystem)try{i.onSystem(t)}catch(e){_(e,"onSystem",t,i.onError)}}}}};return e.addListener(u),e.subscribe({channels:[o],timetoken:1e3}),{unsubscribe(){e.removeListener(u),e.unsubscribe({channels:[o]})}}}(this.getPubNub(),e,t,n)}}function _(e,t,n,s){const i=e instanceof Error?e:new Error(String(e));if(s)try{s(i,{entryPoint:"subscribeToTask",callbackType:t,event:n})}catch{}else S("warn",`subscribeToTask callback error in ${t}`,{event:"subscribe_callback_error",callbackType:t,error:i.message})}
|
|
@@ -40,9 +40,23 @@ export interface TerminalEvent {
|
|
|
40
40
|
error?: string;
|
|
41
41
|
[key: string]: unknown;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Backend-published acknowledgment that a cooperative cancel was committed.
|
|
45
|
+
* Carries no actor identity (the obs.* channel records owner ID for ops/admin
|
|
46
|
+
* audit). Fires zero or once per session: suppressed once a terminal has
|
|
47
|
+
* been delivered (causality), and suppressed on duplicate wire emissions
|
|
48
|
+
* of the event itself (e.g. PubNub cache replay before timetoken-dedup
|
|
49
|
+
* catches it). See `schemas/SDK/task-events/cancel_requested.schema.json`.
|
|
50
|
+
*/
|
|
51
|
+
export interface CancelRequestedEvent {
|
|
52
|
+
type: 'cancel_requested';
|
|
53
|
+
taskId: string;
|
|
54
|
+
ts: number;
|
|
55
|
+
[key: string]: unknown;
|
|
56
|
+
}
|
|
43
57
|
export interface CallbackErrorContext {
|
|
44
58
|
entryPoint: 'taskSession' | 'subscribeToTask';
|
|
45
|
-
callbackType: 'onProgress' | 'onArtifact' | 'onTerminal' | 'onSystem' | 'onEvent' | 'onStream' | 'streamPredicate';
|
|
59
|
+
callbackType: 'onProgress' | 'onArtifact' | 'onTerminal' | 'onCancelRequested' | 'onSystem' | 'onEvent' | 'onStream' | 'streamPredicate';
|
|
46
60
|
event: TaskEvent | StreamRef;
|
|
47
61
|
}
|
|
48
62
|
export declare class TaskSession {
|
|
@@ -61,8 +75,12 @@ export declare class TaskSession {
|
|
|
61
75
|
private progressCallbacks;
|
|
62
76
|
private artifactCallbacks;
|
|
63
77
|
private terminalCallbacks;
|
|
78
|
+
private cancelRequestedCallbacks;
|
|
64
79
|
private eventCallbacks;
|
|
65
80
|
private streamCallbacks;
|
|
81
|
+
private readonly terminalTracker;
|
|
82
|
+
private cancelRequestedDelivered;
|
|
83
|
+
private firstCancelRequested;
|
|
66
84
|
private errorCallbacks;
|
|
67
85
|
private streams;
|
|
68
86
|
private artifacts;
|
|
@@ -160,6 +178,14 @@ export declare class TaskSession {
|
|
|
160
178
|
onProgress(cb: (event: ProgressEvent) => void): Unsubscribe;
|
|
161
179
|
onArtifact(cb: (event: ArtifactEvent) => void): Unsubscribe;
|
|
162
180
|
onTerminal(cb: (event: TerminalEvent) => void): Unsubscribe;
|
|
181
|
+
/**
|
|
182
|
+
* BLOCKS-370: subscribe to backend-published `cancel_requested` events.
|
|
183
|
+
* Fires zero or once per session — suppressed once a terminal has been
|
|
184
|
+
* delivered (causality) and suppressed on duplicate wire emissions of
|
|
185
|
+
* the event itself. Carries `{ taskId, ts }`. No actor identity (the
|
|
186
|
+
* obs.* channel records ownerId for ops/admin audit).
|
|
187
|
+
*/
|
|
188
|
+
onCancelRequested(cb: (event: CancelRequestedEvent) => void): Unsubscribe;
|
|
163
189
|
onEvent(cb: (event: TaskEvent) => void): Unsubscribe;
|
|
164
190
|
/**
|
|
165
191
|
* Register an error handler for callback exceptions.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import t from"pubnub";import{buildPubNubLogConfig as e}from"./pubnub-client.js";import{invertDirection as s}from"../stream/index.js";import{StreamRef as i}from"./stream-ref.js";import{taskChannel as r}from"./channel-manager.js";import{callRpc as a}from"./rpc-client.js";import{downloadArtifact as n}from"./artifacts.js";import{log as o}from"./logger.js";const c=(t,e,s)=>o("[TaskSession]",t,e,s);export const TERMINAL_STATES=new Set(["completed","failed","canceled"]);export class TaskSession{constructor(t){if(this.progressCallbacks=[],this.artifactCallbacks=[],this.terminalCallbacks=[],this.eventCallbacks=[],this.streamCallbacks=[],this.errorCallbacks=[],this.streams=new Map,this.artifacts=[],this.historyEvents=[],this.historyTimetokens=new Set,this.streamWaiters=[],this.terminalWaiters=[],this.closed=!1,this.listener=null,this.terminalReceived=!1,this.drainTimer=null,this.openStreamClients=new Set,this.seenTimetokens=new Set,this.taskId=t.taskId,this.ownerId=t.ownerId,this.orgId=t.orgId??t.ownerId,this.readToken=t.readToken,this.agentName=t.agentName,this.pubnub=t.pubnub,this.ownsSubscribeClient=t.ownsSubscribeClient??!1,this.sdkOptions={...t.sdkOptions,consumerUserId:t.ownerId},this.rpcConfig=t.rpcConfig??null,this.statusChannel=t.statusChannel??r(t.taskId,this.orgId),this.idempotent=t.idempotent,this.queued=t.queued,this.pushConfigId=t.pushConfigId,this.autoDrain=t.autoDrain??!0,this.drainWindowMs=t.drainWindowMs??3e4,this.state=t.state,this.skipSubscriptionMode=t.skipSubscription??!1,this.subscribeKey=t.sdkOptions.subscribeKey,this.publishKey=t.sdkOptions.publishKey??"",t.preloadedStreams)for(const[e,s]of t.preloadedStreams){const t=new i(s.descriptor,this.sdkOptions,{onOpen:t=>this.trackStreamClient(t),sessionState:()=>this.state});this.streams.set(e,t)}t.preloadedArtifacts&&this.artifacts.push(...t.preloadedArtifacts),t.preloadedEvents&&this.historyEvents.push(...t.preloadedEvents),t.preClosed?this.closed=!0:t.skipSubscription||(t.externalSubscription?(this.listener=t.externalSubscription.listener,t.externalSubscription.onReady(this.handleEvent.bind(this))):this.setupSubscription())}setupSubscription(){const t=this.pubnub,e=this.statusChannel;this.listener={message:t=>{if(t.channel!==e)return;const s=t.message;if(!s||"object"!=typeof s||!s.type)return;const i=null!=t.timetoken?String(t.timetoken):void 0;this.handleEvent(s,i)}},t.addListener(this.listener),t.subscribe({channels:[e],timetoken:1e3})}routeCallbackError(t,e,s){const i=t instanceof Error?t:new Error(String(t));if(this.errorCallbacks.length>0)for(const t of this.errorCallbacks)try{t(i,{entryPoint:"taskSession",callbackType:e,event:s})}catch{}else c("warn",`callback error in ${e}`,{event:"task_session_callback_error",callbackType:e,error:i.message})}handleEvent(t,e){if(!this.closed){if(e){if(this.seenTimetokens.has(e))return;if(this.seenTimetokens.add(e),this.seenTimetokens.size>TaskSession.SEEN_TIMETOKENS_MAX){const t=this.seenTimetokens.values().next().value;void 0!==t&&this.seenTimetokens.delete(t)}}for(const e of this.eventCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onEvent",t)}switch(t.type){case"progress":for(const e of this.progressCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onProgress",t)}"stream_started"===t.streamEvent&&t.streams&&this.handleStreamStarted(t);break;case"artifact":this.accumulateArtifact(t);for(const e of this.artifactCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onArtifact",t)}break;case"terminal":this.state=t.state;for(const e of this.terminalCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onTerminal",t)}for(const e of this.terminalWaiters)e.resolve(t);this.terminalWaiters=[],this.autoDrain?this.startAutoDrain():this.close()}}}trackStreamClient(t){this.openStreamClients.add(t),t.onInboundDone(()=>{this.openStreamClients.delete(t),t.isActive&&t.end().catch(()=>{}),this.terminalReceived&&0===this.openStreamClients.size&&(this.drainTimer&&(clearTimeout(this.drainTimer),this.drainTimer=null),this.close())})}accumulateArtifact(t){const e=t.artifactRef;e&&"object"==typeof e&&e.kind&&this.artifacts.push(e)}handleStreamStarted(t){const e=t.streams;if(!e||"object"!=typeof e)return;const r=t.declaredStream;for(const[t,a]of Object.entries(e)){if(!a||"object"!=typeof a)continue;if(this.streams.has(t))continue;const e=a.direction,n=s(e),o=a.format;if(!o||"bytes"!==o&&"events"!==o)continue;const l=a.affinity;if("dedicated"!==l&&"shared"!==l){c("warn",`live stream_started: dropping stream "${t}" for task "${this.taskId}" — invalid or missing affinity`,{event:"task_session_live_invalid_affinity",streamId:t,taskId:this.taskId,receivedAffinity:a.affinity});continue}const h={taskId:this.taskId,streamId:t,agentName:this.agentName,channel:a.channel,token:a.token,agentDirection:e,localDirection:n,format:o,affinity:l,metadata:a.metadata,declaredStream:r},m=new i(h,this.sdkOptions,{onOpen:t=>this.trackStreamClient(t),sessionState:()=>this.state});this.streams.set(t,m);for(const t of this.streamCallbacks)try{t(m)}catch(t){this.routeCallbackError(t,"onStream",m)}this.resolveWaiters(m)}}startAutoDrain(){this.closed||(this.terminalReceived=!0,0!==this.openStreamClients.size?this.drainTimer=setTimeout(()=>{this.drainTimer=null;for(const t of this.openStreamClients)t.isActive&&t.end().catch(()=>{});this.close()},this.drainWindowMs):this.close())}findStreamByIdOrDeclared(t){const e=this.streams.get(t);if(e)return e;for(const e of this.streams.values())if(e.descriptor.declaredStream===t)return e}resolveWaiters(t){const e=[];for(const s of this.streamWaiters){let i=!1;if(void 0!==s.streamId)i=t.descriptor.declaredStream===s.streamId||t.descriptor.streamId===s.streamId;else if(s.predicate)try{i=s.predicate(t)}catch(e){this.routeCallbackError(e,"streamPredicate",t)}else i=!0;i?s.resolve(t):e.push(s)}this.streamWaiters=e}onProgress(t){return this.progressCallbacks.push(t),()=>{this.progressCallbacks=this.progressCallbacks.filter(e=>e!==t)}}onArtifact(t){this.artifactCallbacks.push(t);for(const e of[...this.artifacts]){const s={type:"artifact",taskId:this.taskId,artifactRef:e};try{t(s)}catch(t){this.routeCallbackError(t,"onArtifact",s)}}return()=>{this.artifactCallbacks=this.artifactCallbacks.filter(e=>e!==t)}}onTerminal(t){if(this.terminalCallbacks.push(t),this.state&&TERMINAL_STATES.has(this.state)){const e={type:"terminal",taskId:this.taskId,state:this.state};try{t(e)}catch(t){this.routeCallbackError(t,"onTerminal",e)}}return()=>{this.terminalCallbacks=this.terminalCallbacks.filter(e=>e!==t)}}onEvent(t){return this.eventCallbacks.push(t),()=>{this.eventCallbacks=this.eventCallbacks.filter(e=>e!==t)}}onError(t){return this.errorCallbacks.push(t),()=>{this.errorCallbacks=this.errorCallbacks.filter(e=>e!==t)}}listArtifacts(){return[...this.artifacts]}listEvents(){return[...this.historyEvents]}_appendHistoryEvent(t,e){if(e){if(this.historyTimetokens.has(e))return;this.historyTimetokens.add(e)}this.historyEvents.push(t)}async downloadArtifact(s){if(this.pubnub)return n(s,this.pubnub);const i=`blocks-dl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`,r=new t({subscribeKey:this.subscribeKey,publishKey:this.publishKey||void 0,userId:i,...e()});this.readToken&&r.setToken(this.readToken);try{return await n(s,r)}finally{r.destroy()}}onStream(t){this.streamCallbacks.push(t);for(const e of this.streams.values())try{t(e)}catch(t){this.routeCallbackError(t,"onStream",e)}return()=>{this.streamCallbacks=this.streamCallbacks.filter(e=>e!==t)}}listStreams(){return[...this.streams.values()]}openAllStreams(t){const e=[];for(const s of this.streams.values()){const i=s.descriptor.localDirection;if("inbound"===i||"bidirectional"===i)try{e.push(s.open(t))}catch{}}return e}waitForStream(t){if(this.closed)return Promise.reject(new Error("TaskSession is closed"));if(this.skipSubscriptionMode){if(void 0!==t){const e=this.findStreamByIdOrDeclared(t);if(e)return Promise.resolve(e)}else{if(1===this.streams.size)return Promise.resolve(this.streams.values().next().value);if(this.streams.size>1)return Promise.reject(new Error(`Multiple streams exist (${this.streams.size}). Use waitForStream(streamId) or waitForStreamWhere(predicate) to select one.`))}return Promise.reject(new Error("No matching stream found. This is a terminal task session with no live subscription -- no future stream announcements will arrive."))}if(void 0!==t){const e=this.findStreamByIdOrDeclared(t);if(e)return Promise.resolve(e)}else{if(1===this.streams.size)return Promise.resolve(this.streams.values().next().value);if(this.streams.size>1)return Promise.reject(new Error(`Multiple streams exist (${this.streams.size}). Use waitForStream(streamId) or waitForStreamWhere(predicate) to select one.`))}return new Promise((e,s)=>{this.streamWaiters.push({resolve:e,reject:s,streamId:t})})}waitForStreamWhere(t){if(this.closed)return Promise.reject(new Error("TaskSession is closed"));for(const e of this.streams.values())try{if(t(e))return Promise.resolve(e)}catch(t){this.routeCallbackError(t,"streamPredicate",e)}return this.skipSubscriptionMode?Promise.reject(new Error("No matching stream found. This is a terminal task session with no live subscription -- no future stream announcements will arrive.")):new Promise((e,s)=>{this.streamWaiters.push({resolve:e,reject:s,predicate:t})})}async waitForTerminal(t){if(this.state&&TERMINAL_STATES.has(this.state))return{type:"terminal",taskId:this.taskId,state:this.state};if(this.closed)throw new Error("TaskSession closed");return new Promise((e,s)=>{const i={resolve:e,reject:s};if(this.terminalWaiters.push(i),void 0!==t){const e=setTimeout(()=>{const e=this.terminalWaiters.indexOf(i);-1!==e&&this.terminalWaiters.splice(e,1),s(new Error(`waitForTerminal timed out after ${t}ms`))},t),r=i.resolve,a=i.reject;i.resolve=t=>{clearTimeout(e),r(t)},i.reject=t=>{clearTimeout(e),a(t)}}})}async saveArtifacts(t){const{mkdirSync:e,writeFileSync:s}=await import("node:fs"),{join:i,resolve:r,basename:a}=await import("node:path"),n=r(t);e(n,{recursive:!0});const o=[],c=this.listArtifacts();for(let t=0;t<c.length;t++){const e=await this.downloadArtifact(c[t]),l=e.fileName??`artifact-${t}`,h=r(i(n,a(l)||`artifact-${t}`));if(!h.startsWith(n+"/")&&h!==n)throw new Error(`Artifact filename "${l}" resolves outside target directory`);s(h,e.data),o.push(h)}return o}async cancel(){if(!this.rpcConfig)throw new Error("TaskSession was not created with RPC config; use TaskClient.cancelTask() directly");await a(this.rpcConfig,"CancelTask",{taskId:this.taskId})}async terminate(){if(!this.rpcConfig)throw new Error("TaskSession was not created with RPC config; use TaskClient.terminateTask() directly");await a(this.rpcConfig,"TerminateTask",{taskId:this.taskId})}close(){if(this.closed)return;this.closed=!0,this.drainTimer&&(clearTimeout(this.drainTimer),this.drainTimer=null);for(const t of this.openStreamClients)t.isActive&&t.end().catch(()=>{});this.openStreamClients.clear();const t=new Error("TaskSession closed");for(const e of this.streamWaiters)e.reject(t);this.streamWaiters=[];for(const e of this.terminalWaiters)e.reject(t);this.terminalWaiters=[],this.pubnub&&(this.listener&&(this.pubnub.removeListener(this.listener),this.listener=null),this.pubnub.unsubscribe({channels:[this.statusChannel]}),this.ownsSubscribeClient&&this.pubnub.destroy()),this.progressCallbacks=[],this.artifactCallbacks=[],this.terminalCallbacks=[],this.eventCallbacks=[],this.streamCallbacks=[],this.errorCallbacks=[]}async asyncClose(){if(this.closed)return;const t=[...this.openStreamClients].filter(t=>t.isActive).map(t=>t.end().catch(()=>{}));await Promise.all(t),this.openStreamClients.clear(),this.close()}async[Symbol.asyncDispose](){await this.asyncClose()}get isClosed(){return this.closed}}TaskSession.SEEN_TIMETOKENS_MAX=200;
|
|
1
|
+
import t from"pubnub";import{buildPubNubLogConfig as e}from"./pubnub-client.js";import{invertDirection as s}from"../stream/index.js";import{StreamRef as r}from"./stream-ref.js";import{taskChannel as i}from"./channel-manager.js";import{callRpc as a}from"./rpc-client.js";import{downloadArtifact as n}from"./artifacts.js";import{log as o}from"./logger.js";import{TerminalDeliveryTracker as c}from"./terminal-delivery-tracker.js";const l=(t,e,s)=>o("[TaskSession]",t,e,s);export const TERMINAL_STATES=new Set(["completed","failed","canceled"]);export class TaskSession{constructor(t){if(this.progressCallbacks=[],this.artifactCallbacks=[],this.terminalCallbacks=[],this.cancelRequestedCallbacks=[],this.eventCallbacks=[],this.streamCallbacks=[],this.terminalTracker=new c,this.cancelRequestedDelivered=!1,this.firstCancelRequested=null,this.errorCallbacks=[],this.streams=new Map,this.artifacts=[],this.historyEvents=[],this.historyTimetokens=new Set,this.streamWaiters=[],this.terminalWaiters=[],this.closed=!1,this.listener=null,this.terminalReceived=!1,this.drainTimer=null,this.openStreamClients=new Set,this.seenTimetokens=new Set,this.taskId=t.taskId,this.ownerId=t.ownerId,this.orgId=t.orgId??t.ownerId,this.readToken=t.readToken,this.agentName=t.agentName,this.pubnub=t.pubnub,this.ownsSubscribeClient=t.ownsSubscribeClient??!1,this.sdkOptions={...t.sdkOptions,consumerUserId:t.ownerId},this.rpcConfig=t.rpcConfig??null,this.statusChannel=t.statusChannel??i(t.taskId,this.orgId),this.idempotent=t.idempotent,this.queued=t.queued,this.pushConfigId=t.pushConfigId,this.autoDrain=t.autoDrain??!0,this.drainWindowMs=t.drainWindowMs??3e4,this.state=t.state,this.skipSubscriptionMode=t.skipSubscription??!1,this.subscribeKey=t.sdkOptions.subscribeKey,this.publishKey=t.sdkOptions.publishKey??"",t.preloadedStreams)for(const[e,s]of t.preloadedStreams){const t=new r(s.descriptor,this.sdkOptions,{onOpen:t=>this.trackStreamClient(t),sessionState:()=>this.state});this.streams.set(e,t)}t.preloadedArtifacts&&this.artifacts.push(...t.preloadedArtifacts),t.preloadedEvents&&this.historyEvents.push(...t.preloadedEvents),t.preClosed?this.closed=!0:t.skipSubscription||(t.externalSubscription?(this.listener=t.externalSubscription.listener,t.externalSubscription.onReady(this.handleEvent.bind(this))):this.setupSubscription())}setupSubscription(){const t=this.pubnub,e=this.statusChannel;this.listener={message:t=>{if(t.channel!==e)return;const s=t.message;if(!s||"object"!=typeof s||!s.type)return;const r=null!=t.timetoken?String(t.timetoken):void 0;this.handleEvent(s,r)}},t.addListener(this.listener),t.subscribe({channels:[e],timetoken:1e3})}routeCallbackError(t,e,s){const r=t instanceof Error?t:new Error(String(t));if(this.errorCallbacks.length>0)for(const t of this.errorCallbacks)try{t(r,{entryPoint:"taskSession",callbackType:e,event:s})}catch{}else l("warn",`callback error in ${e}`,{event:"task_session_callback_error",callbackType:e,error:r.message})}handleEvent(t,e){if(!this.closed){if(e){if(this.seenTimetokens.has(e))return;if(this.seenTimetokens.add(e),this.seenTimetokens.size>TaskSession.SEEN_TIMETOKENS_MAX){const t=this.seenTimetokens.values().next().value;void 0!==t&&this.seenTimetokens.delete(t)}}for(const e of this.eventCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onEvent",t)}switch(t.type){case"progress":for(const e of this.progressCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onProgress",t)}"stream_started"===t.streamEvent&&t.streams&&this.handleStreamStarted(t);break;case"artifact":this.accumulateArtifact(t);for(const e of this.artifactCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onArtifact",t)}break;case"cancel_requested":if(this.terminalTracker.isDelivered)break;if(this.cancelRequestedDelivered)break;this.cancelRequestedDelivered=!0,this.firstCancelRequested=t;for(const e of this.cancelRequestedCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onCancelRequested",t)}break;case"terminal":this.terminalTracker.tryDeliver(t,t=>{this.state=t.state;for(const e of this.terminalCallbacks)try{e(t)}catch(e){this.routeCallbackError(e,"onTerminal",t)}for(const e of this.terminalWaiters)e.resolve(t);this.terminalWaiters=[],this.autoDrain?this.startAutoDrain():this.close()})}}}trackStreamClient(t){this.openStreamClients.add(t),t.onInboundDone(()=>{this.openStreamClients.delete(t),t.isActive&&t.end().catch(()=>{}),this.terminalReceived&&0===this.openStreamClients.size&&(this.drainTimer&&(clearTimeout(this.drainTimer),this.drainTimer=null),this.close())})}accumulateArtifact(t){const e=t.artifactRef;e&&"object"==typeof e&&e.kind&&this.artifacts.push(e)}handleStreamStarted(t){const e=t.streams;if(!e||"object"!=typeof e)return;const i=t.declaredStream;for(const[t,a]of Object.entries(e)){if(!a||"object"!=typeof a)continue;if(this.streams.has(t))continue;const e=a.direction,n=s(e),o=a.format;if(!o||"bytes"!==o&&"events"!==o)continue;const c=a.affinity;if("dedicated"!==c&&"shared"!==c){l("warn",`live stream_started: dropping stream "${t}" for task "${this.taskId}" — invalid or missing affinity`,{event:"task_session_live_invalid_affinity",streamId:t,taskId:this.taskId,receivedAffinity:a.affinity});continue}const h={taskId:this.taskId,streamId:t,agentName:this.agentName,channel:a.channel,token:a.token,agentDirection:e,localDirection:n,format:o,affinity:c,metadata:a.metadata,declaredStream:i},d=new r(h,this.sdkOptions,{onOpen:t=>this.trackStreamClient(t),sessionState:()=>this.state});this.streams.set(t,d);for(const t of this.streamCallbacks)try{t(d)}catch(t){this.routeCallbackError(t,"onStream",d)}this.resolveWaiters(d)}}startAutoDrain(){this.closed||(this.terminalReceived=!0,0!==this.openStreamClients.size?this.drainTimer=setTimeout(()=>{this.drainTimer=null;for(const t of this.openStreamClients)t.isActive&&t.end().catch(()=>{});this.close()},this.drainWindowMs):this.close())}findStreamByIdOrDeclared(t){const e=this.streams.get(t);if(e)return e;for(const e of this.streams.values())if(e.descriptor.declaredStream===t)return e}resolveWaiters(t){const e=[];for(const s of this.streamWaiters){let r=!1;if(void 0!==s.streamId)r=t.descriptor.declaredStream===s.streamId||t.descriptor.streamId===s.streamId;else if(s.predicate)try{r=s.predicate(t)}catch(e){this.routeCallbackError(e,"streamPredicate",t)}else r=!0;r?s.resolve(t):e.push(s)}this.streamWaiters=e}onProgress(t){return this.progressCallbacks.push(t),()=>{this.progressCallbacks=this.progressCallbacks.filter(e=>e!==t)}}onArtifact(t){this.artifactCallbacks.push(t);for(const e of[...this.artifacts]){const s={type:"artifact",taskId:this.taskId,artifactRef:e};try{t(s)}catch(t){this.routeCallbackError(t,"onArtifact",s)}}return()=>{this.artifactCallbacks=this.artifactCallbacks.filter(e=>e!==t)}}onTerminal(t){this.terminalCallbacks.push(t);const e=this.terminalTracker.peek();if(e)try{t(e)}catch(t){this.routeCallbackError(t,"onTerminal",e)}else if(this.state&&TERMINAL_STATES.has(this.state)){const e={type:"terminal",taskId:this.taskId,state:this.state};this.terminalTracker.tryDeliver(e,e=>{try{t(e)}catch(t){this.routeCallbackError(t,"onTerminal",e)}})}return()=>{this.terminalCallbacks=this.terminalCallbacks.filter(e=>e!==t)}}onCancelRequested(t){if(this.cancelRequestedCallbacks.push(t),this.firstCancelRequested&&!this.terminalTracker.isDelivered)try{t(this.firstCancelRequested)}catch(t){this.routeCallbackError(t,"onCancelRequested",this.firstCancelRequested)}return()=>{this.cancelRequestedCallbacks=this.cancelRequestedCallbacks.filter(e=>e!==t)}}onEvent(t){return this.eventCallbacks.push(t),()=>{this.eventCallbacks=this.eventCallbacks.filter(e=>e!==t)}}onError(t){return this.errorCallbacks.push(t),()=>{this.errorCallbacks=this.errorCallbacks.filter(e=>e!==t)}}listArtifacts(){return[...this.artifacts]}listEvents(){return[...this.historyEvents]}_appendHistoryEvent(t,e){if(e){if(this.historyTimetokens.has(e))return;this.historyTimetokens.add(e)}this.historyEvents.push(t)}async downloadArtifact(s){if(this.pubnub)return n(s,this.pubnub);const r=`blocks-dl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`,i=new t({subscribeKey:this.subscribeKey,publishKey:this.publishKey||void 0,userId:r,...e()});this.readToken&&i.setToken(this.readToken);try{return await n(s,i)}finally{i.destroy()}}onStream(t){this.streamCallbacks.push(t);for(const e of this.streams.values())try{t(e)}catch(t){this.routeCallbackError(t,"onStream",e)}return()=>{this.streamCallbacks=this.streamCallbacks.filter(e=>e!==t)}}listStreams(){return[...this.streams.values()]}openAllStreams(t){const e=[];for(const s of this.streams.values()){const r=s.descriptor.localDirection;if("inbound"===r||"bidirectional"===r)try{e.push(s.open(t))}catch{}}return e}waitForStream(t){if(this.closed)return Promise.reject(new Error("TaskSession is closed"));if(this.skipSubscriptionMode){if(void 0!==t){const e=this.findStreamByIdOrDeclared(t);if(e)return Promise.resolve(e)}else{if(1===this.streams.size)return Promise.resolve(this.streams.values().next().value);if(this.streams.size>1)return Promise.reject(new Error(`Multiple streams exist (${this.streams.size}). Use waitForStream(streamId) or waitForStreamWhere(predicate) to select one.`))}return Promise.reject(new Error("No matching stream found. This is a terminal task session with no live subscription -- no future stream announcements will arrive."))}if(void 0!==t){const e=this.findStreamByIdOrDeclared(t);if(e)return Promise.resolve(e)}else{if(1===this.streams.size)return Promise.resolve(this.streams.values().next().value);if(this.streams.size>1)return Promise.reject(new Error(`Multiple streams exist (${this.streams.size}). Use waitForStream(streamId) or waitForStreamWhere(predicate) to select one.`))}return new Promise((e,s)=>{this.streamWaiters.push({resolve:e,reject:s,streamId:t})})}waitForStreamWhere(t){if(this.closed)return Promise.reject(new Error("TaskSession is closed"));for(const e of this.streams.values())try{if(t(e))return Promise.resolve(e)}catch(t){this.routeCallbackError(t,"streamPredicate",e)}return this.skipSubscriptionMode?Promise.reject(new Error("No matching stream found. This is a terminal task session with no live subscription -- no future stream announcements will arrive.")):new Promise((e,s)=>{this.streamWaiters.push({resolve:e,reject:s,predicate:t})})}async waitForTerminal(t){const e=this.terminalTracker.peek();if(e)return e;if(this.state&&TERMINAL_STATES.has(this.state)){const t={type:"terminal",taskId:this.taskId,state:this.state};return this.terminalTracker.tryDeliver(t,()=>{}),t}if(this.closed)throw new Error("TaskSession closed");return new Promise((e,s)=>{const r={resolve:e,reject:s};if(this.terminalWaiters.push(r),void 0!==t){const e=setTimeout(()=>{const e=this.terminalWaiters.indexOf(r);-1!==e&&this.terminalWaiters.splice(e,1),s(new Error(`waitForTerminal timed out after ${t}ms`))},t),i=r.resolve,a=r.reject;r.resolve=t=>{clearTimeout(e),i(t)},r.reject=t=>{clearTimeout(e),a(t)}}})}async saveArtifacts(t){const{mkdirSync:e,writeFileSync:s}=await import("node:fs"),{join:r,resolve:i,basename:a}=await import("node:path"),n=i(t);e(n,{recursive:!0});const o=[],c=this.listArtifacts();for(let t=0;t<c.length;t++){const e=await this.downloadArtifact(c[t]),l=e.fileName??`artifact-${t}`,h=i(r(n,a(l)||`artifact-${t}`));if(!h.startsWith(n+"/")&&h!==n)throw new Error(`Artifact filename "${l}" resolves outside target directory`);s(h,e.data),o.push(h)}return o}async cancel(){if(!this.rpcConfig)throw new Error("TaskSession was not created with RPC config; use TaskClient.cancelTask() directly");await a(this.rpcConfig,"CancelTask",{taskId:this.taskId})}async terminate(){if(!this.rpcConfig)throw new Error("TaskSession was not created with RPC config; use TaskClient.terminateTask() directly");await a(this.rpcConfig,"TerminateTask",{taskId:this.taskId})}close(){if(this.closed)return;this.closed=!0,this.drainTimer&&(clearTimeout(this.drainTimer),this.drainTimer=null);for(const t of this.openStreamClients)t.isActive&&t.end().catch(()=>{});this.openStreamClients.clear();const t=new Error("TaskSession closed");for(const e of this.streamWaiters)e.reject(t);this.streamWaiters=[];for(const e of this.terminalWaiters)e.reject(t);this.terminalWaiters=[],this.pubnub&&(this.listener&&(this.pubnub.removeListener(this.listener),this.listener=null),this.pubnub.unsubscribe({channels:[this.statusChannel]}),this.ownsSubscribeClient&&this.pubnub.destroy()),this.progressCallbacks=[],this.artifactCallbacks=[],this.terminalCallbacks=[],this.cancelRequestedCallbacks=[],this.eventCallbacks=[],this.streamCallbacks=[],this.errorCallbacks=[]}async asyncClose(){if(this.closed)return;const t=[...this.openStreamClients].filter(t=>t.isActive).map(t=>t.end().catch(()=>{}));await Promise.all(t),this.openStreamClients.clear(),this.close()}async[Symbol.asyncDispose](){await this.asyncClose()}get isClosed(){return this.closed}}TaskSession.SEEN_TIMETOKENS_MAX=200;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { TerminalEvent } from './task-session.js';
|
|
2
|
+
/**
|
|
3
|
+
* Layer-1 dedup for terminal events.
|
|
4
|
+
*
|
|
5
|
+
* Cooperative cancel and the scanner's Phase-6 safety net can both produce
|
|
6
|
+
* a terminal for the same task. The DB layer (`taskFanout` PubNub Function)
|
|
7
|
+
* already dedupes at the row-status level, but consumers subscribed to the
|
|
8
|
+
* task channel still see two wire-level terminals. This tracker enforces
|
|
9
|
+
* exactly-once delivery to every public terminal callback surface
|
|
10
|
+
* (`TaskSession.onTerminal`, `waitForTerminal`, `subscribeToTask`,
|
|
11
|
+
* synthetic re-emit) so SDK consumers never have to defend against the
|
|
12
|
+
* duplicate themselves.
|
|
13
|
+
*
|
|
14
|
+
* The tracker is single-shot: the *first* terminal observed wins; any
|
|
15
|
+
* later terminal — different state, different reason, different source —
|
|
16
|
+
* is silently dropped. The user's intent (cancel) wins over a stale agent
|
|
17
|
+
* terminal because the scanner publishes after the cancel-budget has
|
|
18
|
+
* elapsed.
|
|
19
|
+
*/
|
|
20
|
+
export declare class TerminalDeliveryTracker {
|
|
21
|
+
private delivered;
|
|
22
|
+
private firstEvent;
|
|
23
|
+
/**
|
|
24
|
+
* Deliver the event through `onDeliver` if no terminal has been
|
|
25
|
+
* delivered yet. Returns `true` when the event was the first and was
|
|
26
|
+
* delivered; `false` when a terminal had already been delivered and the
|
|
27
|
+
* event was dropped. The `delivered` flag is set *before* the callback
|
|
28
|
+
* runs so the callback itself sees `isDelivered === true` (re-entrant
|
|
29
|
+
* safety: prevents recursive delivery via callback side-effects).
|
|
30
|
+
*/
|
|
31
|
+
tryDeliver(evt: TerminalEvent, onDeliver: (e: TerminalEvent) => void): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Returns the first delivered terminal, or `null` if none has been
|
|
34
|
+
* delivered yet. Used by `onTerminal` and `waitForTerminal` to
|
|
35
|
+
* synchronously hand a previously-delivered terminal to a freshly
|
|
36
|
+
* registered observer.
|
|
37
|
+
*/
|
|
38
|
+
peek(): TerminalEvent | null;
|
|
39
|
+
get isDelivered(): boolean;
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class TerminalDeliveryTracker{constructor(){this.delivered=!1,this.firstEvent=null}tryDeliver(e,r){return!this.delivered&&(this.delivered=!0,this.firstEvent=e,r(e),!0)}peek(){return this.firstEvent}get isDelivered(){return this.delivered}}
|