@stina/extension-api 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Background Task Manager (Worker-side)
3
+ *
4
+ * Manages background tasks running inside the extension worker.
5
+ * Handles task execution, AbortController management, and health reporting.
6
+ */
7
+
8
+ import type {
9
+ BackgroundTaskConfig,
10
+ BackgroundTaskCallback,
11
+ BackgroundTaskContext,
12
+ BackgroundTaskHealth,
13
+ Disposable,
14
+ LogAPI,
15
+ } from './types.js'
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Internal representation of a registered task
23
+ */
24
+ interface RegisteredTask {
25
+ config: BackgroundTaskConfig
26
+ callback: BackgroundTaskCallback
27
+ abortController: AbortController | null
28
+ status: 'pending' | 'running' | 'stopped' | 'failed'
29
+ lastHealthStatus?: string
30
+ lastHealthTime?: string
31
+ error?: string
32
+ }
33
+
34
+ /**
35
+ * Options for the WorkerBackgroundTaskManager
36
+ */
37
+ export interface WorkerBackgroundTaskManagerOptions {
38
+ extensionId: string
39
+ extensionVersion: string
40
+ storagePath: string
41
+ /** Send a message to the host */
42
+ sendTaskRegistered: (
43
+ taskId: string,
44
+ name: string,
45
+ userId: string,
46
+ restartPolicy: BackgroundTaskConfig['restartPolicy'],
47
+ payload?: Record<string, unknown>
48
+ ) => void
49
+ /** Send status update to host */
50
+ sendTaskStatus: (taskId: string, status: 'running' | 'stopped' | 'failed', error?: string) => void
51
+ /** Send health report to host */
52
+ sendHealthReport: (taskId: string, status: string, timestamp: string) => void
53
+ /** Create a log API for a task */
54
+ createLogAPI: (taskId: string) => LogAPI
55
+ }
56
+
57
+ // ============================================================================
58
+ // WorkerBackgroundTaskManager
59
+ // ============================================================================
60
+
61
+ /**
62
+ * Manages background tasks within the extension worker.
63
+ */
64
+ export class WorkerBackgroundTaskManager {
65
+ private readonly tasks = new Map<string, RegisteredTask>()
66
+ private readonly options: WorkerBackgroundTaskManagerOptions
67
+
68
+ constructor(options: WorkerBackgroundTaskManagerOptions) {
69
+ this.options = options
70
+ }
71
+
72
+ /**
73
+ * Register and start a background task.
74
+ * @returns A disposable that stops the task when disposed
75
+ */
76
+ async start(config: BackgroundTaskConfig, callback: BackgroundTaskCallback): Promise<Disposable> {
77
+ const { id: taskId } = config
78
+
79
+ if (this.tasks.has(taskId)) {
80
+ throw new Error(`Background task with id '${taskId}' is already registered`)
81
+ }
82
+
83
+ const task: RegisteredTask = {
84
+ config,
85
+ callback,
86
+ abortController: null,
87
+ status: 'pending',
88
+ }
89
+
90
+ this.tasks.set(taskId, task)
91
+
92
+ // Notify host about the registration
93
+ this.options.sendTaskRegistered(
94
+ taskId,
95
+ config.name,
96
+ config.userId,
97
+ config.restartPolicy,
98
+ config.payload
99
+ )
100
+
101
+ // Return a disposable that stops the task
102
+ // Task removal should be coordinated with the host to avoid race conditions
103
+ return {
104
+ dispose: () => {
105
+ this.stop(taskId)
106
+ // Don't immediately delete - let the task finish aborting
107
+ // The task will be cleaned up when the extension is deactivated
108
+ },
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Stop a running task.
114
+ */
115
+ stop(taskId: string): void {
116
+ const task = this.tasks.get(taskId)
117
+ if (!task) {
118
+ return
119
+ }
120
+
121
+ // Abort the task if it's running
122
+ if (task.abortController) {
123
+ task.abortController.abort()
124
+ task.abortController = null
125
+ }
126
+
127
+ task.status = 'stopped'
128
+
129
+ // Notify host that the task has been stopped
130
+ this.options.sendTaskStatus(taskId, 'stopped')
131
+ }
132
+
133
+ /**
134
+ * Handle start message from host.
135
+ * This is called when the host tells us to actually run the task.
136
+ */
137
+ async handleStart(taskId: string): Promise<void> {
138
+ const task = this.tasks.get(taskId)
139
+ if (!task) {
140
+ return
141
+ }
142
+
143
+ // If there's already an execution running, abort it first
144
+ if (task.abortController) {
145
+ task.abortController.abort()
146
+ task.abortController = null
147
+ }
148
+
149
+ // Create new AbortController for this run
150
+ task.abortController = new AbortController()
151
+ task.status = 'running'
152
+ task.error = undefined
153
+
154
+ // Build the task context
155
+ const context = this.buildTaskContext(task)
156
+
157
+ // Notify host that we're running
158
+ this.options.sendTaskStatus(taskId, 'running')
159
+
160
+ try {
161
+ // Execute the callback
162
+ await task.callback(context)
163
+
164
+ // Task completed normally (or was aborted)
165
+ if (task.abortController?.signal.aborted) {
166
+ task.status = 'stopped'
167
+ this.options.sendTaskStatus(taskId, 'stopped')
168
+ } else {
169
+ // Task finished without being aborted - treat as stopped
170
+ task.status = 'stopped'
171
+ this.options.sendTaskStatus(taskId, 'stopped')
172
+ }
173
+ } catch (error) {
174
+ // Task failed with an error
175
+ const errorMessage = error instanceof Error ? error.message : String(error)
176
+ task.status = 'failed'
177
+ task.error = errorMessage
178
+ this.options.sendTaskStatus(taskId, 'failed', errorMessage)
179
+ } finally {
180
+ task.abortController = null
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Handle stop message from host.
186
+ */
187
+ handleStop(taskId: string): void {
188
+ this.stop(taskId)
189
+ }
190
+
191
+ /**
192
+ * Build the execution context for a task.
193
+ */
194
+ private buildTaskContext(task: RegisteredTask): BackgroundTaskContext {
195
+ const { config, abortController } = task
196
+ const signal = abortController!.signal
197
+
198
+ const log = this.options.createLogAPI(config.id)
199
+
200
+ const context: BackgroundTaskContext = {
201
+ userId: config.userId,
202
+ extension: {
203
+ id: this.options.extensionId,
204
+ version: this.options.extensionVersion,
205
+ storagePath: this.options.storagePath,
206
+ },
207
+ signal,
208
+ reportHealth: (status: string) => {
209
+ const timestamp = new Date().toISOString()
210
+ task.lastHealthStatus = status
211
+ task.lastHealthTime = timestamp
212
+ this.options.sendHealthReport(config.id, status, timestamp)
213
+ },
214
+ log,
215
+ }
216
+
217
+ return context
218
+ }
219
+
220
+ /**
221
+ * Get the status of all tasks.
222
+ */
223
+ getStatus(): BackgroundTaskHealth[] {
224
+ const result: BackgroundTaskHealth[] = []
225
+
226
+ for (const task of this.tasks.values()) {
227
+ result.push({
228
+ taskId: task.config.id,
229
+ name: task.config.name,
230
+ userId: task.config.userId,
231
+ status: task.status,
232
+ restartCount: 0, // Worker doesn't track restarts, host does
233
+ lastHealthStatus: task.lastHealthStatus,
234
+ lastHealthTime: task.lastHealthTime,
235
+ error: task.error,
236
+ })
237
+ }
238
+
239
+ return result
240
+ }
241
+
242
+ /**
243
+ * Check if a task exists.
244
+ */
245
+ hasTask(taskId: string): boolean {
246
+ return this.tasks.has(taskId)
247
+ }
248
+
249
+ /**
250
+ * Clean up all tasks.
251
+ * Called during extension deactivation.
252
+ */
253
+ dispose(): void {
254
+ for (const task of this.tasks.values()) {
255
+ if (task.abortController) {
256
+ task.abortController.abort()
257
+ }
258
+ }
259
+ this.tasks.clear()
260
+ }
261
+ }
package/src/index.ts CHANGED
@@ -73,6 +73,14 @@ export type {
73
73
  StorageAPI,
74
74
  LogAPI,
75
75
 
76
+ // Background workers
77
+ BackgroundWorkersAPI,
78
+ BackgroundTaskConfig,
79
+ BackgroundTaskCallback,
80
+ BackgroundTaskContext,
81
+ BackgroundTaskHealth,
82
+ BackgroundRestartPolicy,
83
+
76
84
  // AI Provider
77
85
  AIProvider,
78
86
  ModelInfo,
@@ -117,6 +125,12 @@ export type {
117
125
  StreamEventMessage,
118
126
  LogMessage,
119
127
  PendingRequest,
128
+ // Background task messages
129
+ BackgroundTaskStartMessage,
130
+ BackgroundTaskStopMessage,
131
+ BackgroundTaskRegisteredMessage,
132
+ BackgroundTaskStatusMessage,
133
+ BackgroundTaskHealthMessage,
120
134
  } from './messages.js'
121
135
 
122
136
  export { generateMessageId } from './messages.js'
package/src/messages.ts CHANGED
@@ -28,6 +28,8 @@ export type HostToWorkerMessage =
28
28
  | ActionExecuteRequestMessage
29
29
  | ResponseMessage
30
30
  | StreamingFetchChunkMessage
31
+ | BackgroundTaskStartMessage
32
+ | BackgroundTaskStopMessage
31
33
 
32
34
  export interface ActivateMessage {
33
35
  type: 'activate'
@@ -128,6 +130,28 @@ export interface StreamingFetchChunkMessage {
128
130
  }
129
131
  }
130
132
 
133
+ /**
134
+ * Message sent from host to worker to start a registered background task.
135
+ */
136
+ export interface BackgroundTaskStartMessage {
137
+ type: 'background-task-start'
138
+ id: string
139
+ payload: {
140
+ taskId: string
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Message sent from host to worker to stop a running background task.
146
+ */
147
+ export interface BackgroundTaskStopMessage {
148
+ type: 'background-task-stop'
149
+ id: string
150
+ payload: {
151
+ taskId: string
152
+ }
153
+ }
154
+
131
155
  // ============================================================================
132
156
  // Worker → Host Messages
133
157
  // ============================================================================
@@ -144,6 +168,9 @@ export type WorkerToHostMessage =
144
168
  | ToolExecuteResponseMessage
145
169
  | ActionExecuteResponseMessage
146
170
  | StreamingFetchAckMessage
171
+ | BackgroundTaskRegisteredMessage
172
+ | BackgroundTaskStatusMessage
173
+ | BackgroundTaskHealthMessage
147
174
 
148
175
  export interface ReadyMessage {
149
176
  type: 'ready'
@@ -257,6 +284,50 @@ export interface LogMessage {
257
284
  }
258
285
  }
259
286
 
287
+ /**
288
+ * Message sent from worker to host when a background task is registered.
289
+ */
290
+ export interface BackgroundTaskRegisteredMessage {
291
+ type: 'background-task-registered'
292
+ payload: {
293
+ taskId: string
294
+ name: string
295
+ userId: string
296
+ restartPolicy: {
297
+ type: 'always' | 'on-failure' | 'never'
298
+ maxRestarts?: number
299
+ initialDelayMs?: number
300
+ maxDelayMs?: number
301
+ backoffMultiplier?: number
302
+ }
303
+ payload?: Record<string, unknown>
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Message sent from worker to host with background task status updates.
309
+ */
310
+ export interface BackgroundTaskStatusMessage {
311
+ type: 'background-task-status'
312
+ payload: {
313
+ taskId: string
314
+ status: 'running' | 'stopped' | 'failed'
315
+ error?: string
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Message sent from worker to host with background task health reports.
321
+ */
322
+ export interface BackgroundTaskHealthMessage {
323
+ type: 'background-task-health'
324
+ payload: {
325
+ taskId: string
326
+ status: string
327
+ timestamp: string
328
+ }
329
+ }
330
+
260
331
  // ============================================================================
261
332
  // Utility Types
262
333
  // ============================================================================
package/src/runtime.ts CHANGED
@@ -32,8 +32,14 @@ import type {
32
32
  ChatMessage,
33
33
  ChatOptions,
34
34
  GetModelsOptions,
35
+ BackgroundWorkersAPI,
36
+ BackgroundTaskConfig,
37
+ BackgroundTaskCallback,
38
+ BackgroundTaskHealth,
35
39
  } from './types.js'
36
40
 
41
+ import { WorkerBackgroundTaskManager } from './background.js'
42
+
37
43
  import type {
38
44
  HostToWorkerMessage,
39
45
  WorkerToHostMessage,
@@ -88,6 +94,7 @@ const messagePort = getMessagePort()
88
94
  let extensionModule: ExtensionModule | null = null
89
95
  let extensionDisposable: Disposable | null = null
90
96
  let extensionContext: ExtensionContext | null = null
97
+ let backgroundTaskManager: WorkerBackgroundTaskManager | null = null
91
98
 
92
99
  const pendingRequests = new Map<string, PendingRequest>()
93
100
  const registeredProviders = new Map<string, AIProvider>()
@@ -188,6 +195,14 @@ async function handleHostMessage(message: HostToWorkerMessage): Promise<void> {
188
195
  case 'streaming-fetch-chunk':
189
196
  handleStreamingFetchChunk(message.payload)
190
197
  break
198
+
199
+ case 'background-task-start':
200
+ await handleBackgroundTaskStart(message.payload.taskId)
201
+ break
202
+
203
+ case 'background-task-stop':
204
+ handleBackgroundTaskStop(message.payload.taskId)
205
+ break
191
206
  }
192
207
  }
193
208
 
@@ -284,12 +299,16 @@ async function handleDeactivate(): Promise<void> {
284
299
  if (extensionDisposable) {
285
300
  extensionDisposable.dispose()
286
301
  }
302
+ if (backgroundTaskManager) {
303
+ backgroundTaskManager.dispose()
304
+ }
287
305
  } catch (error) {
288
306
  console.error('Error during deactivation:', error)
289
307
  } finally {
290
308
  extensionModule = null
291
309
  extensionDisposable = null
292
310
  extensionContext = null
311
+ backgroundTaskManager = null
293
312
  registeredProviders.clear()
294
313
  registeredTools.clear()
295
314
  registeredActions.clear()
@@ -332,6 +351,26 @@ async function handleSchedulerFire(payload: SchedulerFirePayload): Promise<void>
332
351
  })
333
352
  }
334
353
 
354
+ // ============================================================================
355
+ // Background Task Handlers
356
+ // ============================================================================
357
+
358
+ async function handleBackgroundTaskStart(taskId: string): Promise<void> {
359
+ if (!backgroundTaskManager) {
360
+ console.error('Background task manager not initialized')
361
+ return
362
+ }
363
+ await backgroundTaskManager.handleStart(taskId)
364
+ }
365
+
366
+ function handleBackgroundTaskStop(taskId: string): void {
367
+ if (!backgroundTaskManager) {
368
+ console.error('Background task manager not initialized')
369
+ return
370
+ }
371
+ backgroundTaskManager.handleStop(taskId)
372
+ }
373
+
335
374
  // ============================================================================
336
375
  // Provider / Tool Requests
337
376
  // ============================================================================
@@ -826,6 +865,73 @@ function buildContext(
826
865
  ;(context as { storage: StorageAPI }).storage = storageApi
827
866
  }
828
867
 
868
+ // Add background workers API if permitted
869
+ if (hasPermission('background.workers')) {
870
+ // Initialize the background task manager if not already done
871
+ if (!backgroundTaskManager) {
872
+ backgroundTaskManager = new WorkerBackgroundTaskManager({
873
+ extensionId,
874
+ extensionVersion,
875
+ storagePath,
876
+ sendTaskRegistered: (taskId, name, userId, restartPolicy, payload) => {
877
+ postMessage({
878
+ type: 'background-task-registered',
879
+ payload: {
880
+ taskId,
881
+ name,
882
+ userId,
883
+ restartPolicy,
884
+ payload,
885
+ },
886
+ })
887
+ },
888
+ sendTaskStatus: (taskId, status, error) => {
889
+ postMessage({
890
+ type: 'background-task-status',
891
+ payload: {
892
+ taskId,
893
+ status,
894
+ error,
895
+ },
896
+ })
897
+ },
898
+ sendHealthReport: (taskId, status, timestamp) => {
899
+ postMessage({
900
+ type: 'background-task-health',
901
+ payload: {
902
+ taskId,
903
+ status,
904
+ timestamp,
905
+ },
906
+ })
907
+ },
908
+ createLogAPI: (taskId) => ({
909
+ debug: (message, data) =>
910
+ postMessage({ type: 'log', payload: { level: 'debug', message: `[${taskId}] ${message}`, data } }),
911
+ info: (message, data) =>
912
+ postMessage({ type: 'log', payload: { level: 'info', message: `[${taskId}] ${message}`, data } }),
913
+ warn: (message, data) =>
914
+ postMessage({ type: 'log', payload: { level: 'warn', message: `[${taskId}] ${message}`, data } }),
915
+ error: (message, data) =>
916
+ postMessage({ type: 'log', payload: { level: 'error', message: `[${taskId}] ${message}`, data } }),
917
+ }),
918
+ })
919
+ }
920
+
921
+ const backgroundWorkersApi: BackgroundWorkersAPI = {
922
+ async start(config: BackgroundTaskConfig, callback: BackgroundTaskCallback): Promise<Disposable> {
923
+ return backgroundTaskManager!.start(config, callback)
924
+ },
925
+ async stop(taskId: string): Promise<void> {
926
+ backgroundTaskManager!.stop(taskId)
927
+ },
928
+ async getStatus(): Promise<BackgroundTaskHealth[]> {
929
+ return backgroundTaskManager!.getStatus()
930
+ },
931
+ }
932
+ ;(context as { backgroundWorkers: BackgroundWorkersAPI }).backgroundWorkers = backgroundWorkersApi
933
+ }
934
+
829
935
  return context
830
936
  }
831
937
 
@@ -871,4 +977,11 @@ export type {
871
977
  ChatOptions,
872
978
  GetModelsOptions,
873
979
  StreamEvent,
980
+ // Background workers
981
+ BackgroundWorkersAPI,
982
+ BackgroundTaskConfig,
983
+ BackgroundTaskCallback,
984
+ BackgroundTaskContext,
985
+ BackgroundTaskHealth,
986
+ BackgroundRestartPolicy,
874
987
  } from './types.js'