@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 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}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocks-network/sdk",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "type": "module",
5
5
  "description": "Blocks Network SDK",
6
6
  "exports": {