pi-simple-background-tasks 0.0.0-stage → 0.1.0

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,83 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { CONFIG_DIR_NAME, getAgentDir } from '@earendil-works/pi-coding-agent';
5
+
6
+ /** Config file name this extension reads, following the PI extension-config pattern. */
7
+ export const PI_BG_CONFIG_FILE_NAME = 'pi-background-tasks.json';
8
+
9
+ export interface PiBackgroundConfig {
10
+ readonly tasksDir: string;
11
+ }
12
+
13
+ interface BackgroundTasksConfigFile {
14
+ readonly tasksDir?: string;
15
+ }
16
+
17
+ /** Default tasks directory: the PI agent directory plus `tasks`. */
18
+ export function defaultTasksDir(agentDir: string = getAgentDir()): string {
19
+ return join(agentDir, 'tasks');
20
+ }
21
+
22
+ function configError(file: string, reason: string): never {
23
+ throw new Error(`pi_bg_config_invalid: ${file} ${reason}`);
24
+ }
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ function readConfigFile(file: string): BackgroundTasksConfigFile | undefined {
31
+ let text: string;
32
+ try {
33
+ text = readFileSync(file, 'utf8');
34
+ } catch (error) {
35
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
36
+ throw error;
37
+ }
38
+ let parsed: unknown;
39
+ try {
40
+ parsed = JSON.parse(text);
41
+ } catch (error) {
42
+ configError(
43
+ file,
44
+ `is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
45
+ );
46
+ }
47
+ if (!isRecord(parsed)) configError(file, 'must contain a JSON object');
48
+ const unknown = Object.keys(parsed).filter((key) => key !== 'tasksDir');
49
+ if (unknown.length > 0) configError(file, `contains unknown keys: ${unknown.join(', ')}`);
50
+ if (parsed.tasksDir !== undefined && typeof parsed.tasksDir !== 'string')
51
+ configError(file, 'has a non-string tasksDir');
52
+ return parsed as BackgroundTasksConfigFile;
53
+ }
54
+
55
+ function resolveTasksDir(raw: string, configFileDir: string): string {
56
+ const expanded = raw === '~' || raw.startsWith('~/') ? join(homedir(), raw.slice(1)) : raw;
57
+ return isAbsolute(expanded) ? expanded : resolve(configFileDir, expanded);
58
+ }
59
+
60
+ /**
61
+ * Resolve extension configuration following the PI extension-config pattern:
62
+ * the global file under `<agent-dir>/extensions/` plus a project file under
63
+ * `<cwd>/.pi/`; project keys override global keys. Absent keys keep their
64
+ * defaults.
65
+ */
66
+ export function resolveBackgroundTasksConfig(
67
+ cwd: string,
68
+ agentDir: string = getAgentDir(),
69
+ ): PiBackgroundConfig {
70
+ const files: Array<{ file: string; dir: string }> = [
71
+ { file: join(agentDir, 'extensions', PI_BG_CONFIG_FILE_NAME), dir: agentDir },
72
+ { file: join(cwd, CONFIG_DIR_NAME, PI_BG_CONFIG_FILE_NAME), dir: cwd },
73
+ ];
74
+ let tasksDir: string | undefined;
75
+ for (const { file, dir } of files) {
76
+ const parsed = readConfigFile(file);
77
+ if (parsed === undefined) continue;
78
+ if (parsed.tasksDir !== undefined) tasksDir = resolveTasksDir(parsed.tasksDir, dir);
79
+ }
80
+ return Object.freeze({
81
+ tasksDir: tasksDir ?? defaultTasksDir(agentDir),
82
+ });
83
+ }
@@ -0,0 +1,545 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { open as nodeOpen, rename as nodeRename, rm as nodeRm } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ export type DurableData = Buffer | string;
6
+ export type DurableWriteFlag = 'w' | 'wx';
7
+ export type DurableOperation =
8
+ | 'open_file'
9
+ | 'write_file'
10
+ | 'sync_file'
11
+ | 'close_file'
12
+ | 'rename_file'
13
+ | 'remove_temp'
14
+ | 'open_directory'
15
+ | 'sync_directory'
16
+ | 'close_directory';
17
+
18
+ export interface DurableFailure {
19
+ operation: DurableOperation;
20
+ path: string;
21
+ cause: unknown;
22
+ }
23
+
24
+ export interface DurableWritableHandle {
25
+ writeFile(data: DurableData): Promise<void>;
26
+ sync(): Promise<void>;
27
+ close(): Promise<void>;
28
+ }
29
+
30
+ export interface DurableDirectoryHandle {
31
+ sync(): Promise<void>;
32
+ close(): Promise<void>;
33
+ }
34
+
35
+ export interface DurableFileOperations {
36
+ platform: NodeJS.Platform;
37
+ openWritable(path: string, flag: DurableWriteFlag, mode?: number): Promise<DurableWritableHandle>;
38
+ openDirectory(path: string): Promise<DurableDirectoryHandle>;
39
+ rename(source: string, target: string): Promise<void>;
40
+ remove(path: string): Promise<void>;
41
+ temporaryPath(target: string): string;
42
+ }
43
+
44
+ export interface DurableWriteOptions {
45
+ readonly signal?: AbortSignal | undefined;
46
+ }
47
+
48
+ export interface DurableFileWriter {
49
+ write(path: string, data: DurableData, options?: DurableWriteOptions): Promise<void>;
50
+ replace(path: string, data: DurableData, options?: DurableWriteOptions): Promise<void>;
51
+ }
52
+
53
+ interface DurableErrorInput {
54
+ operation: DurableOperation;
55
+ path: string;
56
+ cause: unknown;
57
+ cleanupFailures: readonly DurableFailure[];
58
+ targetPath: string | undefined;
59
+ temporaryPath: string | undefined;
60
+ renameCompleted: boolean;
61
+ }
62
+
63
+ interface WritableSequenceResult {
64
+ primaryFailure: DurableFailure | undefined;
65
+ cleanupFailures: DurableFailure[];
66
+ cancelled: boolean;
67
+ }
68
+
69
+ interface RenameSequenceResult {
70
+ error: unknown | undefined;
71
+ cancelled: boolean;
72
+ }
73
+
74
+ export class DurableFileCancellationError extends Error {
75
+ readonly code = 'durable_file_cancelled';
76
+ readonly path: string;
77
+ readonly reason: unknown;
78
+ readonly cleanupFailures: readonly DurableFailure[];
79
+ readonly targetPath: string | undefined;
80
+ readonly temporaryPath: string | undefined;
81
+ readonly renameCompleted: boolean;
82
+
83
+ constructor(input: {
84
+ path: string;
85
+ reason: unknown;
86
+ cleanupFailures: readonly DurableFailure[];
87
+ targetPath: string | undefined;
88
+ temporaryPath: string | undefined;
89
+ renameCompleted: boolean;
90
+ }) {
91
+ const reasonText = describeCause(input.reason);
92
+ const cleanupText =
93
+ input.cleanupFailures.length === 0
94
+ ? ''
95
+ : ` Cleanup failures: ${input.cleanupFailures.map(formatFailureForMessage).join('; ')}`;
96
+ const commitText = input.renameCompleted
97
+ ? ` Replacement may already be visible at ${input.targetPath ?? input.path}.`
98
+ : '';
99
+ super(
100
+ `Durable file operation cancelled for ${input.path}: ${reasonText}.${commitText}${cleanupText}`,
101
+ );
102
+ this.name = 'DurableFileCancellationError';
103
+ this.path = input.path;
104
+ this.reason = input.reason;
105
+ this.cleanupFailures = [...input.cleanupFailures];
106
+ this.targetPath = input.targetPath;
107
+ this.temporaryPath = input.temporaryPath;
108
+ this.renameCompleted = input.renameCompleted;
109
+ }
110
+ }
111
+
112
+ export class DurableFileError extends Error {
113
+ readonly operation: DurableOperation;
114
+ readonly path: string;
115
+ readonly nativeCode: string | undefined;
116
+ readonly primaryCause: unknown;
117
+ readonly cleanupFailures: readonly DurableFailure[];
118
+ readonly targetPath: string | undefined;
119
+ readonly temporaryPath: string | undefined;
120
+ readonly renameCompleted: boolean;
121
+
122
+ constructor(input: {
123
+ operation: DurableOperation;
124
+ path: string;
125
+ cause: unknown;
126
+ cleanupFailures: readonly DurableFailure[];
127
+ targetPath: string | undefined;
128
+ temporaryPath: string | undefined;
129
+ renameCompleted: boolean;
130
+ }) {
131
+ super(formatDurableMessage(input));
132
+ this.name = 'DurableFileError';
133
+ this.operation = input.operation;
134
+ this.path = input.path;
135
+ this.nativeCode = nativeCodeForCause(input.cause);
136
+ this.primaryCause = input.cause;
137
+ this.cleanupFailures = [...input.cleanupFailures];
138
+ this.targetPath = input.targetPath;
139
+ this.temporaryPath = input.temporaryPath;
140
+ this.renameCompleted = input.renameCompleted;
141
+ }
142
+ }
143
+
144
+ function failure(operation: DurableOperation, path: string, cause: unknown): DurableFailure {
145
+ return { operation, path, cause };
146
+ }
147
+
148
+ function durableError(input: DurableErrorInput): DurableFileError {
149
+ return new DurableFileError(input);
150
+ }
151
+
152
+ function nativeCodeForCause(cause: unknown): string | undefined {
153
+ if (typeof cause !== 'object' || cause === null) return undefined;
154
+ const code = Reflect.get(cause, 'code');
155
+ return typeof code === 'string' ? code : undefined;
156
+ }
157
+
158
+ function describeCause(cause: unknown): string {
159
+ if (cause instanceof Error) {
160
+ const message = cause.message.length > 0 ? cause.message : String(cause);
161
+ return `${cause.name}: ${message}`;
162
+ }
163
+ return String(cause);
164
+ }
165
+
166
+ function formatFailureForMessage(entry: DurableFailure): string {
167
+ const code = nativeCodeForCause(entry.cause);
168
+ const codeText = code === undefined ? '' : ` (code ${code})`;
169
+ return `${entry.operation} ${entry.path}${codeText}: ${describeCause(entry.cause)}`;
170
+ }
171
+
172
+ function formatDurableMessage(input: DurableErrorInput): string {
173
+ const primary = formatFailureForMessage({
174
+ operation: input.operation,
175
+ path: input.path,
176
+ cause: input.cause,
177
+ });
178
+ const segments = [`Durable file operation failed: ${primary}`];
179
+ if (input.targetPath !== undefined) segments.push(`target: ${input.targetPath}`);
180
+ if (input.temporaryPath !== undefined) segments.push(`temporary: ${input.temporaryPath}`);
181
+ if (input.renameCompleted) {
182
+ const target = input.targetPath ?? input.path;
183
+ segments.push(`Replacement may already be visible at ${target}.`);
184
+ }
185
+ if (input.cleanupFailures.length > 0) {
186
+ const cleanupText = input.cleanupFailures.map(formatFailureForMessage).join('; ');
187
+ segments.push(`Cleanup failures: ${cleanupText}`);
188
+ }
189
+ return segments.join(' ');
190
+ }
191
+
192
+ async function writeSyncClose(
193
+ handle: DurableWritableHandle,
194
+ path: string,
195
+ data: DurableData,
196
+ signal?: AbortSignal,
197
+ ): Promise<WritableSequenceResult> {
198
+ const cleanupFailures: DurableFailure[] = [];
199
+ let primaryFailure: DurableFailure | undefined;
200
+ let cancelled = signal?.aborted === true;
201
+ if (!cancelled) {
202
+ try {
203
+ await handle.writeFile(data);
204
+ } catch (error) {
205
+ primaryFailure = failure('write_file', path, error);
206
+ }
207
+ cancelled = signal?.aborted === true;
208
+ }
209
+ if (primaryFailure === undefined && !cancelled) {
210
+ try {
211
+ await handle.sync();
212
+ } catch (error) {
213
+ primaryFailure = failure('sync_file', path, error);
214
+ }
215
+ cancelled = signal?.aborted === true;
216
+ }
217
+ try {
218
+ await handle.close();
219
+ } catch (error) {
220
+ const closeFailure = failure('close_file', path, error);
221
+ if (primaryFailure === undefined) primaryFailure = closeFailure;
222
+ else cleanupFailures.push(closeFailure);
223
+ }
224
+ cancelled ||= signal?.aborted === true;
225
+ return { primaryFailure, cleanupFailures, cancelled };
226
+ }
227
+
228
+ async function syncCloseDirectory(
229
+ handle: DurableDirectoryHandle,
230
+ path: string,
231
+ ): Promise<WritableSequenceResult> {
232
+ const cleanupFailures: DurableFailure[] = [];
233
+ let primaryFailure: DurableFailure | undefined;
234
+ try {
235
+ await handle.sync();
236
+ } catch (error) {
237
+ primaryFailure = failure('sync_directory', path, error);
238
+ }
239
+ try {
240
+ await handle.close();
241
+ } catch (error) {
242
+ const closeFailure = failure('close_directory', path, error);
243
+ if (primaryFailure === undefined) primaryFailure = closeFailure;
244
+ else cleanupFailures.push(closeFailure);
245
+ }
246
+ return { primaryFailure, cleanupFailures, cancelled: false };
247
+ }
248
+
249
+ function cancellationReason(signal: AbortSignal): unknown {
250
+ return signal.reason ?? new Error('operation aborted');
251
+ }
252
+
253
+ function throwCancellation(
254
+ signal: AbortSignal,
255
+ path: string,
256
+ cleanupFailures: readonly DurableFailure[],
257
+ targetPath: string | undefined,
258
+ temporaryPath: string | undefined,
259
+ renameCompleted: boolean,
260
+ ): never {
261
+ const reason = cancellationReason(signal);
262
+ if (cleanupFailures.length === 0 && !renameCompleted && reason instanceof Error) throw reason;
263
+ throw new DurableFileCancellationError({
264
+ path,
265
+ reason,
266
+ cleanupFailures,
267
+ targetPath,
268
+ temporaryPath,
269
+ renameCompleted,
270
+ });
271
+ }
272
+
273
+ function throwDurable(
274
+ primaryFailure: DurableFailure,
275
+ cleanupFailures: readonly DurableFailure[],
276
+ targetPath: string | undefined,
277
+ temporaryPath: string | undefined,
278
+ renameCompleted: boolean,
279
+ ): never {
280
+ throw durableError({
281
+ operation: primaryFailure.operation,
282
+ path: primaryFailure.path,
283
+ cause: primaryFailure.cause,
284
+ cleanupFailures,
285
+ targetPath,
286
+ temporaryPath,
287
+ renameCompleted,
288
+ });
289
+ }
290
+
291
+ async function removeTemporary(
292
+ operations: DurableFileOperations,
293
+ temporaryPath: string,
294
+ cleanupFailures: DurableFailure[],
295
+ ): Promise<void> {
296
+ try {
297
+ await operations.remove(temporaryPath);
298
+ } catch (error) {
299
+ cleanupFailures.push(failure('remove_temp', temporaryPath, error));
300
+ }
301
+ }
302
+
303
+ function isAborted(signal: AbortSignal | undefined): boolean {
304
+ return signal?.aborted === true;
305
+ }
306
+
307
+ async function writeWithOperations(
308
+ operations: DurableFileOperations,
309
+ path: string,
310
+ data: DurableData,
311
+ options: DurableWriteOptions,
312
+ ): Promise<void> {
313
+ const signal = options.signal;
314
+ if (signal !== undefined && isAborted(signal))
315
+ throwCancellation(signal, path, [], path, undefined, false);
316
+ let handle: DurableWritableHandle;
317
+ try {
318
+ handle = await operations.openWritable(path, 'w');
319
+ } catch (error) {
320
+ if (signal !== undefined && isAborted(signal))
321
+ throwCancellation(signal, path, [], path, undefined, false);
322
+ throwDurable(failure('open_file', path, error), [], path, undefined, false);
323
+ }
324
+ const result = await writeSyncClose(handle, path, data, signal);
325
+ if (result.primaryFailure !== undefined) {
326
+ throwDurable(result.primaryFailure, result.cleanupFailures, path, undefined, false);
327
+ }
328
+ if (result.cancelled && signal !== undefined) {
329
+ throwCancellation(signal, path, result.cleanupFailures, path, undefined, false);
330
+ }
331
+ }
332
+
333
+ async function syncDirectoryAfterRename(
334
+ operations: DurableFileOperations,
335
+ targetPath: string,
336
+ temporaryPath: string,
337
+ ): Promise<void> {
338
+ if (operations.platform === 'win32') return;
339
+ const directoryPath = dirname(targetPath);
340
+ let handle: DurableDirectoryHandle;
341
+ try {
342
+ handle = await operations.openDirectory(directoryPath);
343
+ } catch (error) {
344
+ throwDurable(
345
+ failure('open_directory', directoryPath, error),
346
+ [],
347
+ targetPath,
348
+ temporaryPath,
349
+ true,
350
+ );
351
+ }
352
+ const result = await syncCloseDirectory(handle, directoryPath);
353
+ if (result.primaryFailure !== undefined) {
354
+ throwDurable(result.primaryFailure, result.cleanupFailures, targetPath, temporaryPath, true);
355
+ }
356
+ }
357
+
358
+ async function replaceWithOperations(
359
+ operations: DurableFileOperations,
360
+ path: string,
361
+ data: DurableData,
362
+ options: DurableWriteOptions,
363
+ ): Promise<void> {
364
+ const signal = options.signal;
365
+ if (signal !== undefined && isAborted(signal))
366
+ throwCancellation(signal, path, [], path, undefined, false);
367
+ const temporaryPath = operations.temporaryPath(path);
368
+ let handle: DurableWritableHandle;
369
+ try {
370
+ handle = await operations.openWritable(temporaryPath, 'wx', 0o600);
371
+ } catch (error) {
372
+ if (signal !== undefined && isAborted(signal))
373
+ throwCancellation(signal, temporaryPath, [], path, temporaryPath, false);
374
+ throwDurable(failure('open_file', temporaryPath, error), [], path, temporaryPath, false);
375
+ }
376
+ const writeResult = await writeSyncClose(handle, temporaryPath, data, signal);
377
+ if (writeResult.primaryFailure !== undefined) {
378
+ await removeTemporary(operations, temporaryPath, writeResult.cleanupFailures);
379
+ throwDurable(
380
+ writeResult.primaryFailure,
381
+ writeResult.cleanupFailures,
382
+ path,
383
+ temporaryPath,
384
+ false,
385
+ );
386
+ }
387
+ if (writeResult.cancelled && signal !== undefined) {
388
+ await removeTemporary(operations, temporaryPath, writeResult.cleanupFailures);
389
+ throwCancellation(
390
+ signal,
391
+ temporaryPath,
392
+ writeResult.cleanupFailures,
393
+ path,
394
+ temporaryPath,
395
+ false,
396
+ );
397
+ }
398
+ const renameResult = await renameWithWindowsContention(operations, temporaryPath, path, signal);
399
+ if (renameResult.cancelled && signal !== undefined) {
400
+ const cleanupFailures: DurableFailure[] = [];
401
+ await removeTemporary(operations, temporaryPath, cleanupFailures);
402
+ throwCancellation(signal, temporaryPath, cleanupFailures, path, temporaryPath, false);
403
+ }
404
+ if (renameResult.error !== undefined) {
405
+ const cleanupFailures: DurableFailure[] = [];
406
+ await removeTemporary(operations, temporaryPath, cleanupFailures);
407
+ throwDurable(
408
+ failure('rename_file', path, renameResult.error),
409
+ cleanupFailures,
410
+ path,
411
+ temporaryPath,
412
+ false,
413
+ );
414
+ }
415
+ await syncDirectoryAfterRename(operations, path, temporaryPath);
416
+ if (signal !== undefined && isAborted(signal)) {
417
+ throwCancellation(signal, path, [], path, temporaryPath, true);
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Windows sharing-violation codes for a replace-rename.
423
+ *
424
+ * POSIX `rename(2)` atomically replaces the target. Windows `MoveFileEx` fails
425
+ * with a sharing violation when another process momentarily holds the target
426
+ * open, including transient scanners, indexers, and concurrent writers. The
427
+ * operation is legitimate and simply needs to be reattempted.
428
+ */
429
+ const WINDOWS_RENAME_CONTENTION_CODES: ReadonlySet<string> = new Set(['EPERM', 'EACCES', 'EBUSY']);
430
+
431
+ const WINDOWS_RENAME_ATTEMPTS = 10;
432
+ const WINDOWS_RENAME_RETRY_DELAY_MS = 20;
433
+
434
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
435
+ if (isAborted(signal)) return Promise.resolve();
436
+ return new Promise((resolve) => {
437
+ const timer = setTimeout(() => {
438
+ signal?.removeEventListener('abort', aborted);
439
+ resolve();
440
+ }, ms);
441
+ const aborted = () => {
442
+ clearTimeout(timer);
443
+ resolve();
444
+ };
445
+ signal?.addEventListener('abort', aborted, { once: true });
446
+ });
447
+ }
448
+
449
+ /**
450
+ * Rename the temporary file over the target, retrying only Windows sharing
451
+ * violations.
452
+ *
453
+ * This is a bounded retry of a transient OS condition, not a fallback: the
454
+ * final failure is still returned and raised loudly, no alternative write path
455
+ * is taken, and no other platform or error code is retried.
456
+ *
457
+ * Returns an explicit success/error/cancellation sequence result.
458
+ */
459
+ async function renameWithWindowsContention(
460
+ operations: DurableFileOperations,
461
+ temporaryPath: string,
462
+ targetPath: string,
463
+ signal?: AbortSignal,
464
+ ): Promise<RenameSequenceResult> {
465
+ const attempts = operations.platform === 'win32' ? WINDOWS_RENAME_ATTEMPTS : 1;
466
+ let lastError: unknown;
467
+ for (let attempt = 1; attempt <= attempts; attempt++) {
468
+ if (isAborted(signal)) return { error: undefined, cancelled: true };
469
+ try {
470
+ await operations.rename(temporaryPath, targetPath);
471
+ return { error: undefined, cancelled: false };
472
+ } catch (error) {
473
+ lastError = error;
474
+ if (isAborted(signal)) return { error: undefined, cancelled: true };
475
+ const code = nativeCodeForCause(error);
476
+ const retryable =
477
+ operations.platform === 'win32' &&
478
+ code !== undefined &&
479
+ WINDOWS_RENAME_CONTENTION_CODES.has(code);
480
+ if (!retryable || attempt === attempts) return { error, cancelled: false };
481
+ await delay(WINDOWS_RENAME_RETRY_DELAY_MS * attempt, signal);
482
+ }
483
+ }
484
+ return { error: lastError, cancelled: false };
485
+ }
486
+
487
+ function temporaryPathForTarget(target: string): string {
488
+ return join(
489
+ dirname(target),
490
+ `.${basename(target)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
491
+ );
492
+ }
493
+
494
+ const nodeOperations: DurableFileOperations = {
495
+ platform: process.platform,
496
+ async openWritable(path: string, flag: DurableWriteFlag, mode?: number) {
497
+ if (mode === undefined) return nodeOpen(path, flag);
498
+ return nodeOpen(path, flag, mode);
499
+ },
500
+ async openDirectory(path: string) {
501
+ return nodeOpen(path, 'r');
502
+ },
503
+ async rename(source: string, target: string) {
504
+ await nodeRename(source, target);
505
+ },
506
+ async remove(path: string) {
507
+ // `force` ignores a missing path only. Permission and other failures still
508
+ // throw and are surfaced as `remove_temp` cleanup failures.
509
+ await nodeRm(path, { force: true });
510
+ },
511
+ temporaryPath: temporaryPathForTarget,
512
+ };
513
+
514
+ const defaultWriter = createDurableFileWriter(nodeOperations);
515
+
516
+ export function createDurableFileWriter(operations: DurableFileOperations): DurableFileWriter {
517
+ return {
518
+ async write(path: string, data: DurableData, options: DurableWriteOptions = {}): Promise<void> {
519
+ await writeWithOperations(operations, path, data, options);
520
+ },
521
+ async replace(
522
+ path: string,
523
+ data: DurableData,
524
+ options: DurableWriteOptions = {},
525
+ ): Promise<void> {
526
+ await replaceWithOperations(operations, path, data, options);
527
+ },
528
+ };
529
+ }
530
+
531
+ export async function writeFileDurable(
532
+ path: string,
533
+ data: DurableData,
534
+ options: DurableWriteOptions = {},
535
+ ): Promise<void> {
536
+ await defaultWriter.write(path, data, options);
537
+ }
538
+
539
+ export async function replaceFileDurable(
540
+ path: string,
541
+ data: DurableData,
542
+ options: DurableWriteOptions = {},
543
+ ): Promise<void> {
544
+ await defaultWriter.replace(path, data, options);
545
+ }
@@ -0,0 +1,70 @@
1
+ import type { EventBus } from '@earendil-works/pi-coding-agent';
2
+ import type { BgTaskSnapshot } from './common.js';
3
+ import type { BackgroundTaskRegistry } from './registry.js';
4
+
5
+ export const BG_TERMINAL_CHANNEL = 'pi-background-tasks:terminal:v1';
6
+ export const BG_TERMINAL_SCHEMA = 'pi-background-tasks.extension-terminal.v1';
7
+ export const BG_EXTENSION_SERVICE_CLOSED_CODE = 'pi_background_tasks_eventbus_closed';
8
+
9
+ export type BackgroundTaskExtensionServiceState = 'open' | 'closed';
10
+
11
+ export class BackgroundTaskExtensionServiceClosedError extends Error {
12
+ readonly code = BG_EXTENSION_SERVICE_CLOSED_CODE;
13
+
14
+ constructor() {
15
+ super('pi-background-tasks EventBus service is closed');
16
+ this.name = 'BackgroundTaskExtensionServiceClosedError';
17
+ }
18
+ }
19
+
20
+ export interface BackgroundTaskExtensionTerminal {
21
+ schema_version: typeof BG_TERMINAL_SCHEMA;
22
+ task: BgTaskSnapshot;
23
+ }
24
+
25
+ export interface BackgroundTaskExtensionService {
26
+ readonly state: BackgroundTaskExtensionServiceState;
27
+ publishTerminal(task: BgTaskSnapshot): void;
28
+ close(): void;
29
+ }
30
+
31
+ export interface BackgroundTaskExtensionServiceOptions {
32
+ events: EventBus;
33
+ registry: BackgroundTaskRegistry;
34
+ }
35
+
36
+ class InstalledBackgroundTaskExtensionService implements BackgroundTaskExtensionService {
37
+ private readonly events: EventBus;
38
+ private readonly registry: BackgroundTaskRegistry;
39
+ private serviceState: BackgroundTaskExtensionServiceState = 'open';
40
+
41
+ constructor(options: BackgroundTaskExtensionServiceOptions) {
42
+ this.events = options.events;
43
+ this.registry = options.registry;
44
+ }
45
+
46
+ get state(): BackgroundTaskExtensionServiceState {
47
+ return this.serviceState;
48
+ }
49
+
50
+ publishTerminal(task: BgTaskSnapshot): void {
51
+ if (this.serviceState === 'closed') throw new BackgroundTaskExtensionServiceClosedError();
52
+ const terminal: BackgroundTaskExtensionTerminal = {
53
+ schema_version: BG_TERMINAL_SCHEMA,
54
+ task,
55
+ };
56
+ this.events.emit(BG_TERMINAL_CHANNEL, terminal);
57
+ }
58
+
59
+ close(): void {
60
+ if (this.serviceState === 'closed') return;
61
+ this.serviceState = 'closed';
62
+ this.registry.closeTerminalPublication('publisher_closed');
63
+ }
64
+ }
65
+
66
+ export function installBackgroundTaskExtensionApi(
67
+ options: BackgroundTaskExtensionServiceOptions,
68
+ ): BackgroundTaskExtensionService {
69
+ return new InstalledBackgroundTaskExtensionService(options);
70
+ }