@superdoc/sdk 2.7.0 → 2.9.0-next.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.
@@ -8,6 +8,7 @@ var documentRpc = require('./document-rpc.cjs');
8
8
 
9
9
  const HOST_PROTOCOL_VERSION = '1.0';
10
10
  const CLI_HOST_REQUIRED_FEATURES = ['cli.invoke', 'host.shutdown'];
11
+ const COLLABORATION_AUTH_PER_OPEN_FEATURE = 'collaboration.auth.perOpen';
11
12
  const DOCUMENT_HOST_REQUIRED_FEATURES = [...documentRpc.DOCUMENT_RPC_FEATURES, 'host.shutdown'];
12
13
  const CHANGE_MODES = ['direct', 'tracked'];
13
14
  const FORWARD_HOST_STDERR = typeof process !== 'undefined' && typeof process.env?.SUPERDOC_DEBUG_TEXT_REWRITE === 'string'
@@ -98,9 +99,15 @@ class HostTransport {
98
99
  pending = new Map();
99
100
  nextRequestId = 1;
100
101
  connecting = null;
102
+ disposePromise = null;
103
+ terminalDisposalError = null;
101
104
  stopping = false;
105
+ activeCalls = new Set();
102
106
  hostFeatures = new Set();
103
107
  documentRpcSessions = new Set();
108
+ dirtyDocumentRpcSessions = new Set();
109
+ documentRpcMutationStates = new Map();
110
+ indeterminateDiscardCloses = new Set();
104
111
  constructor(options) {
105
112
  this.hostBin = options.hostBin;
106
113
  this.processMode = options.processMode ?? 'cli';
@@ -121,12 +128,43 @@ class HostTransport {
121
128
  this.documentRpcEnabled = this.processMode === 'document' || documentRpc.documentRpcEnabled(options.env);
122
129
  }
123
130
  async connect() {
124
- await this.ensureConnected();
131
+ await this.runWhileActive(() => this.ensureConnected());
125
132
  }
126
133
  async dispose() {
127
- if (!this.child)
134
+ if (this.disposePromise)
135
+ return this.disposePromise;
136
+ if (this.terminalDisposalError)
137
+ throw this.terminalDisposalError;
138
+ if (!this.child && !this.connecting && this.activeCalls.size === 0)
128
139
  return;
129
140
  this.stopping = true;
141
+ const disposal = this.disposeAfterActiveCalls();
142
+ this.disposePromise = disposal;
143
+ try {
144
+ await disposal;
145
+ }
146
+ finally {
147
+ if (this.disposePromise === disposal)
148
+ this.disposePromise = null;
149
+ this.stopping = false;
150
+ }
151
+ }
152
+ async disposeAfterActiveCalls() {
153
+ await Promise.allSettled(Array.from(this.activeCalls));
154
+ if (this.terminalDisposalError)
155
+ throw this.terminalDisposalError;
156
+ if (!this.child)
157
+ return;
158
+ try {
159
+ await this.reconcileIndeterminateDiscardCloses();
160
+ await this.persistDirtyDocumentRpcSessions();
161
+ }
162
+ catch (error) {
163
+ if (!this.child && error instanceof errors.SuperDocCliError) {
164
+ this.terminalDisposalError = error;
165
+ }
166
+ throw error;
167
+ }
130
168
  const child = this.child;
131
169
  try {
132
170
  await this.sendJsonRpcRequest('host.shutdown', {}, this.shutdownTimeoutMs);
@@ -145,10 +183,32 @@ class HostTransport {
145
183
  });
146
184
  });
147
185
  this.cleanupProcess(null);
148
- this.stopping = false;
149
186
  }
150
187
  async invoke(operation, params = {}, options = {}) {
188
+ return this.runWhileActive(() => this.invokeWhileActive(operation, params, options));
189
+ }
190
+ async invokeWhileActive(operation, params, options) {
191
+ const collaborationAuth = options.collaborationAuth === undefined ? undefined : transportCommon.normalizeCollaborationAuth(options.collaborationAuth);
192
+ if (collaborationAuth !== undefined) {
193
+ if (operation.operationId !== 'doc.open') {
194
+ throw new errors.SuperDocCliError('collaborationAuth is supported only for doc.open requests.', {
195
+ code: 'INVALID_ARGUMENT',
196
+ });
197
+ }
198
+ if (this.processMode !== 'cli') {
199
+ throw new errors.SuperDocCliError('Per-open collaboration authentication is not supported by the standalone document host.', {
200
+ code: 'CAPABILITY_UNSUPPORTED',
201
+ details: { feature: COLLABORATION_AUTH_PER_OPEN_FEATURE },
202
+ });
203
+ }
204
+ }
151
205
  await this.ensureConnected();
206
+ if (collaborationAuth !== undefined && !this.hostFeatures.has(COLLABORATION_AUTH_PER_OPEN_FEATURE)) {
207
+ throw new errors.SuperDocCliError('The connected CLI host does not support per-open collaboration authentication.', {
208
+ code: 'CAPABILITY_UNSUPPORTED',
209
+ details: { feature: COLLABORATION_AUTH_PER_OPEN_FEATURE },
210
+ });
211
+ }
152
212
  const shouldOpenThroughCliTimeoutFallback = this.processMode === 'cli' &&
153
213
  (this.requestTimeoutMs !== undefined ||
154
214
  (options.timeoutMs !== undefined && !this.hostFeatures.has(documentRpc.DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE)));
@@ -163,6 +223,7 @@ class HostTransport {
163
223
  const response = await this.sendJsonRpcRequest('document.open', opened.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
164
224
  const result = documentRpc.mapDocumentOpenResult(response, opened.sessionId, params.doc);
165
225
  this.documentRpcSessions.add(opened.sessionId);
226
+ this.indeterminateDiscardCloses.delete(opened.sessionId);
166
227
  return result;
167
228
  }
168
229
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined;
@@ -172,10 +233,50 @@ class HostTransport {
172
233
  const requestTimeoutMs = supportsResponseTimeout
173
234
  ? this.resolveDocumentRequestTimeout(options.timeoutMs)
174
235
  : undefined;
175
- const response = await this.sendJsonRpcRequest(request.method, request.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
176
- const result = documentRpc.mapDocumentLifecycleResult(operation.operationId, response, sessionId, operation.operationId === 'doc.save' ? request.params.path === undefined : undefined);
177
- if (operation.operationId === 'doc.close')
236
+ const documentOperation = operation;
237
+ const requestOptions = typeof request.params.options === 'object' &&
238
+ request.params.options !== null &&
239
+ !Array.isArray(request.params.options)
240
+ ? request.params.options
241
+ : undefined;
242
+ const tracksMutation = request.method === 'document.invoke' && documentOperation.mutates === true && requestOptions?.dryRun !== true;
243
+ if (tracksMutation)
244
+ this.beginDocumentRpcMutation(sessionId);
245
+ let result;
246
+ try {
247
+ const response = await this.sendJsonRpcRequest(request.method, request.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
248
+ result = documentRpc.mapDocumentLifecycleResult(operation.operationId, response, sessionId, operation.operationId === 'doc.save' ? request.params.path === undefined : undefined);
249
+ }
250
+ catch (error) {
251
+ if (tracksMutation) {
252
+ const indeterminate = error instanceof errors.SuperDocCliError && ['TIMEOUT', 'HOST_TIMEOUT', 'HOST_DISCONNECTED'].includes(error.code);
253
+ this.settleDocumentRpcMutation(sessionId, indeterminate ? 'indeterminate' : 'not-applied');
254
+ }
255
+ if (operation.operationId === 'doc.close' &&
256
+ request.params.discard === true &&
257
+ error instanceof errors.SuperDocCliError &&
258
+ error.code === 'TIMEOUT') {
259
+ this.indeterminateDiscardCloses.add(sessionId);
260
+ }
261
+ throw error;
262
+ }
263
+ if (tracksMutation) {
264
+ const failedReceipt = typeof result === 'object' &&
265
+ result !== null &&
266
+ !Array.isArray(result) &&
267
+ result.success === false;
268
+ this.settleDocumentRpcMutation(sessionId, failedReceipt ? 'not-applied' : 'applied');
269
+ }
270
+ if (operation.operationId === 'doc.close') {
178
271
  this.documentRpcSessions.delete(sessionId);
272
+ this.clearDirtyDocumentRpcSession(sessionId);
273
+ this.indeterminateDiscardCloses.delete(sessionId);
274
+ }
275
+ else if (operation.operationId === 'doc.save') {
276
+ if (request.params.mode === undefined || request.params.mode === 'review-preserving') {
277
+ this.markDocumentRpcSessionSaved(sessionId);
278
+ }
279
+ }
179
280
  return result;
180
281
  }
181
282
  if (this.processMode === 'document') {
@@ -202,6 +303,7 @@ class HostTransport {
202
303
  const response = await this.sendJsonRpcRequest('cli.invoke', {
203
304
  argv,
204
305
  stdinBase64,
306
+ ...(collaborationAuth === undefined ? {} : { collaborationAuth }),
205
307
  }, watchdogTimeout);
206
308
  if (typeof response !== 'object' || response == null || Array.isArray(response)) {
207
309
  throw new errors.SuperDocCliError('Host returned invalid cli.invoke result.', {
@@ -212,6 +314,20 @@ class HostTransport {
212
314
  const resultRecord = response;
213
315
  return mapCliInvocationResult(operation, resultRecord.data);
214
316
  }
317
+ runWhileActive(start) {
318
+ if (this.terminalDisposalError) {
319
+ return Promise.reject(this.terminalDisposalError);
320
+ }
321
+ if (this.stopping) {
322
+ return Promise.reject(new errors.SuperDocCliError('Host is disposing.', {
323
+ code: 'HOST_DISCONNECTED',
324
+ }));
325
+ }
326
+ const activeCall = start();
327
+ this.activeCalls.add(activeCall);
328
+ void activeCall.then(() => this.activeCalls.delete(activeCall), () => this.activeCalls.delete(activeCall));
329
+ return activeCall;
330
+ }
215
331
  async ensureConnected() {
216
332
  if (this.connecting) {
217
333
  await this.connecting;
@@ -264,7 +380,7 @@ class HostTransport {
264
380
  }));
265
381
  });
266
382
  child.on('close', (code, signal) => {
267
- if (this.stopping) {
383
+ if (this.stopping && this.dirtyDocumentRpcSessions.size === 0) {
268
384
  this.cleanupProcess(null);
269
385
  return;
270
386
  }
@@ -350,6 +466,65 @@ class HostTransport {
350
466
  details: { feature: documentRpc.DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE },
351
467
  });
352
468
  }
469
+ beginDocumentRpcMutation(sessionId) {
470
+ const state = this.documentRpcMutationStates.get(sessionId) ?? {
471
+ confirmedDirty: this.dirtyDocumentRpcSessions.has(sessionId),
472
+ pendingCount: 0,
473
+ };
474
+ state.pendingCount += 1;
475
+ this.documentRpcMutationStates.set(sessionId, state);
476
+ this.dirtyDocumentRpcSessions.add(sessionId);
477
+ }
478
+ settleDocumentRpcMutation(sessionId, outcome) {
479
+ const state = this.documentRpcMutationStates.get(sessionId);
480
+ if (!state)
481
+ return;
482
+ state.pendingCount = Math.max(0, state.pendingCount - 1);
483
+ if (outcome !== 'not-applied')
484
+ state.confirmedDirty = true;
485
+ if (state.confirmedDirty || state.pendingCount > 0) {
486
+ this.dirtyDocumentRpcSessions.add(sessionId);
487
+ return;
488
+ }
489
+ this.clearDirtyDocumentRpcSession(sessionId);
490
+ }
491
+ clearDirtyDocumentRpcSession(sessionId) {
492
+ this.dirtyDocumentRpcSessions.delete(sessionId);
493
+ this.documentRpcMutationStates.delete(sessionId);
494
+ }
495
+ markDocumentRpcSessionSaved(sessionId) {
496
+ const state = this.documentRpcMutationStates.get(sessionId);
497
+ if (!state || state.pendingCount === 0) {
498
+ this.clearDirtyDocumentRpcSession(sessionId);
499
+ return;
500
+ }
501
+ state.confirmedDirty = false;
502
+ this.dirtyDocumentRpcSessions.add(sessionId);
503
+ }
504
+ async persistDirtyDocumentRpcSessions() {
505
+ if (this.dirtyDocumentRpcSessions.size === 0)
506
+ return;
507
+ if (!this.hostFeatures.has(documentRpc.DOCUMENT_RPC_SOURCE_SAVE_FEATURE)) {
508
+ throw new errors.SuperDocCliError('Document host cannot persist dirty sessions during client disposal because it does not support source saves.', {
509
+ code: 'DOCUMENT_RPC_INPUT_UNSUPPORTED',
510
+ details: { feature: documentRpc.DOCUMENT_RPC_SOURCE_SAVE_FEATURE },
511
+ });
512
+ }
513
+ for (const sessionId of this.dirtyDocumentRpcSessions) {
514
+ const response = await this.sendJsonRpcRequest('document.save', { sessionId }, this.resolveWatchdogTimeout(undefined));
515
+ documentRpc.mapDocumentLifecycleResult('doc.save', response, sessionId, true);
516
+ this.markDocumentRpcSessionSaved(sessionId);
517
+ }
518
+ }
519
+ async reconcileIndeterminateDiscardCloses() {
520
+ for (const sessionId of this.indeterminateDiscardCloses) {
521
+ const response = await this.sendJsonRpcRequest('document.close', { sessionId, discard: true }, this.resolveWatchdogTimeout(undefined));
522
+ documentRpc.mapDocumentLifecycleResult('doc.close', response, sessionId);
523
+ this.documentRpcSessions.delete(sessionId);
524
+ this.clearDirtyDocumentRpcSession(sessionId);
525
+ this.indeterminateDiscardCloses.delete(sessionId);
526
+ }
527
+ }
353
528
  async sendJsonRpcRequest(method, params, watchdogTimeoutMs, metadata = {}) {
354
529
  const child = this.child;
355
530
  if (!child || !child.stdin.writable) {
@@ -481,8 +656,17 @@ class HostTransport {
481
656
  this.cleanupProcess(error);
482
657
  }
483
658
  cleanupProcess(error) {
659
+ if (error && this.dirtyDocumentRpcSessions.size > 0 && !this.terminalDisposalError) {
660
+ this.terminalDisposalError = new errors.SuperDocCliError('Document host disconnected before dirty sessions could be persisted.', {
661
+ code: 'HOST_DISCONNECTED',
662
+ details: { causeCode: error.code, causeDetails: error.details },
663
+ });
664
+ }
484
665
  this.hostFeatures.clear();
485
666
  this.documentRpcSessions.clear();
667
+ this.dirtyDocumentRpcSessions.clear();
668
+ this.documentRpcMutationStates.clear();
669
+ this.indeterminateDiscardCloses.clear();
486
670
  const child = this.child;
487
671
  if (child) {
488
672
  child.removeAllListeners();
@@ -507,6 +691,7 @@ class HostTransport {
507
691
  }
508
692
  }
509
693
 
694
+ exports.COLLABORATION_AUTH_PER_OPEN_FEATURE = COLLABORATION_AUTH_PER_OPEN_FEATURE;
510
695
  exports.HostTransport = HostTransport;
511
696
  exports.buildDocumentHostSpawnArgs = buildDocumentHostSpawnArgs;
512
697
  exports.buildHostSpawnArgs = buildHostSpawnArgs;
@@ -1,4 +1,5 @@
1
- import { type InvokeOptions, type OperationSpec, type SuperDocClientOptions } from './transport-common.js';
1
+ import { type OperationSpec, type SuperDocClientOptions, type TransportInvokeOptions } from './transport-common.js';
2
+ export declare const COLLABORATION_AUTH_PER_OPEN_FEATURE = "collaboration.auth.perOpen";
2
3
  export declare function mapCliInvocationResult(operation: OperationSpec, value: unknown): unknown;
3
4
  /**
4
5
  * Builds the argv passed to `spawn` for `superdoc host --stdio`. Propagates
@@ -46,21 +47,36 @@ export declare class HostTransport {
46
47
  private readonly pending;
47
48
  private nextRequestId;
48
49
  private connecting;
50
+ private disposePromise;
51
+ private terminalDisposalError;
49
52
  private stopping;
53
+ private readonly activeCalls;
50
54
  private readonly hostFeatures;
51
55
  private readonly documentRpcSessions;
56
+ private readonly dirtyDocumentRpcSessions;
57
+ private readonly documentRpcMutationStates;
58
+ private readonly indeterminateDiscardCloses;
52
59
  constructor(options: {
53
60
  hostBin: string;
54
61
  processMode?: 'cli' | 'document';
55
62
  } & SuperDocClientOptions);
56
63
  connect(): Promise<void>;
57
64
  dispose(): Promise<void>;
58
- invoke<TData = unknown>(operation: OperationSpec, params?: Record<string, unknown>, options?: InvokeOptions): Promise<TData>;
65
+ private disposeAfterActiveCalls;
66
+ invoke<TData = unknown>(operation: OperationSpec, params?: Record<string, unknown>, options?: TransportInvokeOptions): Promise<TData>;
67
+ private invokeWhileActive;
68
+ private runWhileActive;
59
69
  private ensureConnected;
60
70
  private startHostProcess;
61
71
  private assertCapabilities;
62
72
  private resolveWatchdogTimeout;
63
73
  private resolveDocumentRequestTimeout;
74
+ private beginDocumentRpcMutation;
75
+ private settleDocumentRpcMutation;
76
+ private clearDirtyDocumentRpcSession;
77
+ private markDocumentRpcSessionSaved;
78
+ private persistDirtyDocumentRpcSessions;
79
+ private reconcileIndeterminateDiscardCloses;
64
80
  private sendJsonRpcRequest;
65
81
  private onStdoutLine;
66
82
  private mapJsonRpcError;
@@ -1,10 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
- import { buildOperationArgv, resolveInvocation, } from './transport-common.js';
3
+ import { buildOperationArgv, normalizeCollaborationAuth, resolveInvocation, } from './transport-common.js';
4
4
  import { SuperDocCliError } from './errors.js';
5
- import { DOCUMENT_RPC_FEATURES, DOCUMENT_RPC_DEFAULT_CHANGE_MODE_FEATURE, DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE, buildDocumentInvokeParams, buildDocumentOpenParams, canOpenWithDocumentRpc, documentRpcRequestSupportsResponseTimeout, documentRpcEnabled, mapDocumentLifecycleResult, mapDocumentOpenResult, supportsDocumentRpc, } from './document-rpc.js';
5
+ import { DOCUMENT_RPC_FEATURES, DOCUMENT_RPC_DEFAULT_CHANGE_MODE_FEATURE, DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE, DOCUMENT_RPC_SOURCE_SAVE_FEATURE, buildDocumentInvokeParams, buildDocumentOpenParams, canOpenWithDocumentRpc, documentRpcRequestSupportsResponseTimeout, documentRpcEnabled, mapDocumentLifecycleResult, mapDocumentOpenResult, supportsDocumentRpc, } from './document-rpc.js';
6
6
  const HOST_PROTOCOL_VERSION = '1.0';
7
7
  const CLI_HOST_REQUIRED_FEATURES = ['cli.invoke', 'host.shutdown'];
8
+ export const COLLABORATION_AUTH_PER_OPEN_FEATURE = 'collaboration.auth.perOpen';
8
9
  const DOCUMENT_HOST_REQUIRED_FEATURES = [...DOCUMENT_RPC_FEATURES, 'host.shutdown'];
9
10
  const CHANGE_MODES = ['direct', 'tracked'];
10
11
  const FORWARD_HOST_STDERR = typeof process !== 'undefined' && typeof process.env?.SUPERDOC_DEBUG_TEXT_REWRITE === 'string'
@@ -95,9 +96,15 @@ export class HostTransport {
95
96
  pending = new Map();
96
97
  nextRequestId = 1;
97
98
  connecting = null;
99
+ disposePromise = null;
100
+ terminalDisposalError = null;
98
101
  stopping = false;
102
+ activeCalls = new Set();
99
103
  hostFeatures = new Set();
100
104
  documentRpcSessions = new Set();
105
+ dirtyDocumentRpcSessions = new Set();
106
+ documentRpcMutationStates = new Map();
107
+ indeterminateDiscardCloses = new Set();
101
108
  constructor(options) {
102
109
  this.hostBin = options.hostBin;
103
110
  this.processMode = options.processMode ?? 'cli';
@@ -118,12 +125,43 @@ export class HostTransport {
118
125
  this.documentRpcEnabled = this.processMode === 'document' || documentRpcEnabled(options.env);
119
126
  }
120
127
  async connect() {
121
- await this.ensureConnected();
128
+ await this.runWhileActive(() => this.ensureConnected());
122
129
  }
123
130
  async dispose() {
124
- if (!this.child)
131
+ if (this.disposePromise)
132
+ return this.disposePromise;
133
+ if (this.terminalDisposalError)
134
+ throw this.terminalDisposalError;
135
+ if (!this.child && !this.connecting && this.activeCalls.size === 0)
125
136
  return;
126
137
  this.stopping = true;
138
+ const disposal = this.disposeAfterActiveCalls();
139
+ this.disposePromise = disposal;
140
+ try {
141
+ await disposal;
142
+ }
143
+ finally {
144
+ if (this.disposePromise === disposal)
145
+ this.disposePromise = null;
146
+ this.stopping = false;
147
+ }
148
+ }
149
+ async disposeAfterActiveCalls() {
150
+ await Promise.allSettled(Array.from(this.activeCalls));
151
+ if (this.terminalDisposalError)
152
+ throw this.terminalDisposalError;
153
+ if (!this.child)
154
+ return;
155
+ try {
156
+ await this.reconcileIndeterminateDiscardCloses();
157
+ await this.persistDirtyDocumentRpcSessions();
158
+ }
159
+ catch (error) {
160
+ if (!this.child && error instanceof SuperDocCliError) {
161
+ this.terminalDisposalError = error;
162
+ }
163
+ throw error;
164
+ }
127
165
  const child = this.child;
128
166
  try {
129
167
  await this.sendJsonRpcRequest('host.shutdown', {}, this.shutdownTimeoutMs);
@@ -142,10 +180,32 @@ export class HostTransport {
142
180
  });
143
181
  });
144
182
  this.cleanupProcess(null);
145
- this.stopping = false;
146
183
  }
147
184
  async invoke(operation, params = {}, options = {}) {
185
+ return this.runWhileActive(() => this.invokeWhileActive(operation, params, options));
186
+ }
187
+ async invokeWhileActive(operation, params, options) {
188
+ const collaborationAuth = options.collaborationAuth === undefined ? undefined : normalizeCollaborationAuth(options.collaborationAuth);
189
+ if (collaborationAuth !== undefined) {
190
+ if (operation.operationId !== 'doc.open') {
191
+ throw new SuperDocCliError('collaborationAuth is supported only for doc.open requests.', {
192
+ code: 'INVALID_ARGUMENT',
193
+ });
194
+ }
195
+ if (this.processMode !== 'cli') {
196
+ throw new SuperDocCliError('Per-open collaboration authentication is not supported by the standalone document host.', {
197
+ code: 'CAPABILITY_UNSUPPORTED',
198
+ details: { feature: COLLABORATION_AUTH_PER_OPEN_FEATURE },
199
+ });
200
+ }
201
+ }
148
202
  await this.ensureConnected();
203
+ if (collaborationAuth !== undefined && !this.hostFeatures.has(COLLABORATION_AUTH_PER_OPEN_FEATURE)) {
204
+ throw new SuperDocCliError('The connected CLI host does not support per-open collaboration authentication.', {
205
+ code: 'CAPABILITY_UNSUPPORTED',
206
+ details: { feature: COLLABORATION_AUTH_PER_OPEN_FEATURE },
207
+ });
208
+ }
149
209
  const shouldOpenThroughCliTimeoutFallback = this.processMode === 'cli' &&
150
210
  (this.requestTimeoutMs !== undefined ||
151
211
  (options.timeoutMs !== undefined && !this.hostFeatures.has(DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE)));
@@ -160,6 +220,7 @@ export class HostTransport {
160
220
  const response = await this.sendJsonRpcRequest('document.open', opened.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
161
221
  const result = mapDocumentOpenResult(response, opened.sessionId, params.doc);
162
222
  this.documentRpcSessions.add(opened.sessionId);
223
+ this.indeterminateDiscardCloses.delete(opened.sessionId);
163
224
  return result;
164
225
  }
165
226
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined;
@@ -169,10 +230,50 @@ export class HostTransport {
169
230
  const requestTimeoutMs = supportsResponseTimeout
170
231
  ? this.resolveDocumentRequestTimeout(options.timeoutMs)
171
232
  : undefined;
172
- const response = await this.sendJsonRpcRequest(request.method, request.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
173
- const result = mapDocumentLifecycleResult(operation.operationId, response, sessionId, operation.operationId === 'doc.save' ? request.params.path === undefined : undefined);
174
- if (operation.operationId === 'doc.close')
233
+ const documentOperation = operation;
234
+ const requestOptions = typeof request.params.options === 'object' &&
235
+ request.params.options !== null &&
236
+ !Array.isArray(request.params.options)
237
+ ? request.params.options
238
+ : undefined;
239
+ const tracksMutation = request.method === 'document.invoke' && documentOperation.mutates === true && requestOptions?.dryRun !== true;
240
+ if (tracksMutation)
241
+ this.beginDocumentRpcMutation(sessionId);
242
+ let result;
243
+ try {
244
+ const response = await this.sendJsonRpcRequest(request.method, request.params, this.resolveWatchdogTimeout(options.timeoutMs), { requestTimeoutMs });
245
+ result = mapDocumentLifecycleResult(operation.operationId, response, sessionId, operation.operationId === 'doc.save' ? request.params.path === undefined : undefined);
246
+ }
247
+ catch (error) {
248
+ if (tracksMutation) {
249
+ const indeterminate = error instanceof SuperDocCliError && ['TIMEOUT', 'HOST_TIMEOUT', 'HOST_DISCONNECTED'].includes(error.code);
250
+ this.settleDocumentRpcMutation(sessionId, indeterminate ? 'indeterminate' : 'not-applied');
251
+ }
252
+ if (operation.operationId === 'doc.close' &&
253
+ request.params.discard === true &&
254
+ error instanceof SuperDocCliError &&
255
+ error.code === 'TIMEOUT') {
256
+ this.indeterminateDiscardCloses.add(sessionId);
257
+ }
258
+ throw error;
259
+ }
260
+ if (tracksMutation) {
261
+ const failedReceipt = typeof result === 'object' &&
262
+ result !== null &&
263
+ !Array.isArray(result) &&
264
+ result.success === false;
265
+ this.settleDocumentRpcMutation(sessionId, failedReceipt ? 'not-applied' : 'applied');
266
+ }
267
+ if (operation.operationId === 'doc.close') {
175
268
  this.documentRpcSessions.delete(sessionId);
269
+ this.clearDirtyDocumentRpcSession(sessionId);
270
+ this.indeterminateDiscardCloses.delete(sessionId);
271
+ }
272
+ else if (operation.operationId === 'doc.save') {
273
+ if (request.params.mode === undefined || request.params.mode === 'review-preserving') {
274
+ this.markDocumentRpcSessionSaved(sessionId);
275
+ }
276
+ }
176
277
  return result;
177
278
  }
178
279
  if (this.processMode === 'document') {
@@ -199,6 +300,7 @@ export class HostTransport {
199
300
  const response = await this.sendJsonRpcRequest('cli.invoke', {
200
301
  argv,
201
302
  stdinBase64,
303
+ ...(collaborationAuth === undefined ? {} : { collaborationAuth }),
202
304
  }, watchdogTimeout);
203
305
  if (typeof response !== 'object' || response == null || Array.isArray(response)) {
204
306
  throw new SuperDocCliError('Host returned invalid cli.invoke result.', {
@@ -209,6 +311,20 @@ export class HostTransport {
209
311
  const resultRecord = response;
210
312
  return mapCliInvocationResult(operation, resultRecord.data);
211
313
  }
314
+ runWhileActive(start) {
315
+ if (this.terminalDisposalError) {
316
+ return Promise.reject(this.terminalDisposalError);
317
+ }
318
+ if (this.stopping) {
319
+ return Promise.reject(new SuperDocCliError('Host is disposing.', {
320
+ code: 'HOST_DISCONNECTED',
321
+ }));
322
+ }
323
+ const activeCall = start();
324
+ this.activeCalls.add(activeCall);
325
+ void activeCall.then(() => this.activeCalls.delete(activeCall), () => this.activeCalls.delete(activeCall));
326
+ return activeCall;
327
+ }
212
328
  async ensureConnected() {
213
329
  if (this.connecting) {
214
330
  await this.connecting;
@@ -261,7 +377,7 @@ export class HostTransport {
261
377
  }));
262
378
  });
263
379
  child.on('close', (code, signal) => {
264
- if (this.stopping) {
380
+ if (this.stopping && this.dirtyDocumentRpcSessions.size === 0) {
265
381
  this.cleanupProcess(null);
266
382
  return;
267
383
  }
@@ -347,6 +463,65 @@ export class HostTransport {
347
463
  details: { feature: DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE },
348
464
  });
349
465
  }
466
+ beginDocumentRpcMutation(sessionId) {
467
+ const state = this.documentRpcMutationStates.get(sessionId) ?? {
468
+ confirmedDirty: this.dirtyDocumentRpcSessions.has(sessionId),
469
+ pendingCount: 0,
470
+ };
471
+ state.pendingCount += 1;
472
+ this.documentRpcMutationStates.set(sessionId, state);
473
+ this.dirtyDocumentRpcSessions.add(sessionId);
474
+ }
475
+ settleDocumentRpcMutation(sessionId, outcome) {
476
+ const state = this.documentRpcMutationStates.get(sessionId);
477
+ if (!state)
478
+ return;
479
+ state.pendingCount = Math.max(0, state.pendingCount - 1);
480
+ if (outcome !== 'not-applied')
481
+ state.confirmedDirty = true;
482
+ if (state.confirmedDirty || state.pendingCount > 0) {
483
+ this.dirtyDocumentRpcSessions.add(sessionId);
484
+ return;
485
+ }
486
+ this.clearDirtyDocumentRpcSession(sessionId);
487
+ }
488
+ clearDirtyDocumentRpcSession(sessionId) {
489
+ this.dirtyDocumentRpcSessions.delete(sessionId);
490
+ this.documentRpcMutationStates.delete(sessionId);
491
+ }
492
+ markDocumentRpcSessionSaved(sessionId) {
493
+ const state = this.documentRpcMutationStates.get(sessionId);
494
+ if (!state || state.pendingCount === 0) {
495
+ this.clearDirtyDocumentRpcSession(sessionId);
496
+ return;
497
+ }
498
+ state.confirmedDirty = false;
499
+ this.dirtyDocumentRpcSessions.add(sessionId);
500
+ }
501
+ async persistDirtyDocumentRpcSessions() {
502
+ if (this.dirtyDocumentRpcSessions.size === 0)
503
+ return;
504
+ if (!this.hostFeatures.has(DOCUMENT_RPC_SOURCE_SAVE_FEATURE)) {
505
+ throw new SuperDocCliError('Document host cannot persist dirty sessions during client disposal because it does not support source saves.', {
506
+ code: 'DOCUMENT_RPC_INPUT_UNSUPPORTED',
507
+ details: { feature: DOCUMENT_RPC_SOURCE_SAVE_FEATURE },
508
+ });
509
+ }
510
+ for (const sessionId of this.dirtyDocumentRpcSessions) {
511
+ const response = await this.sendJsonRpcRequest('document.save', { sessionId }, this.resolveWatchdogTimeout(undefined));
512
+ mapDocumentLifecycleResult('doc.save', response, sessionId, true);
513
+ this.markDocumentRpcSessionSaved(sessionId);
514
+ }
515
+ }
516
+ async reconcileIndeterminateDiscardCloses() {
517
+ for (const sessionId of this.indeterminateDiscardCloses) {
518
+ const response = await this.sendJsonRpcRequest('document.close', { sessionId, discard: true }, this.resolveWatchdogTimeout(undefined));
519
+ mapDocumentLifecycleResult('doc.close', response, sessionId);
520
+ this.documentRpcSessions.delete(sessionId);
521
+ this.clearDirtyDocumentRpcSession(sessionId);
522
+ this.indeterminateDiscardCloses.delete(sessionId);
523
+ }
524
+ }
350
525
  async sendJsonRpcRequest(method, params, watchdogTimeoutMs, metadata = {}) {
351
526
  const child = this.child;
352
527
  if (!child || !child.stdin.writable) {
@@ -478,8 +653,17 @@ export class HostTransport {
478
653
  this.cleanupProcess(error);
479
654
  }
480
655
  cleanupProcess(error) {
656
+ if (error && this.dirtyDocumentRpcSessions.size > 0 && !this.terminalDisposalError) {
657
+ this.terminalDisposalError = new SuperDocCliError('Document host disconnected before dirty sessions could be persisted.', {
658
+ code: 'HOST_DISCONNECTED',
659
+ details: { causeCode: error.code, causeDetails: error.details },
660
+ });
661
+ }
481
662
  this.hostFeatures.clear();
482
663
  this.documentRpcSessions.clear();
664
+ this.dirtyDocumentRpcSessions.clear();
665
+ this.documentRpcMutationStates.clear();
666
+ this.indeterminateDiscardCloses.clear();
483
667
  const child = this.child;
484
668
  if (child) {
485
669
  child.removeAllListeners();
@@ -32,6 +32,10 @@ function resolveRuntimeProcess(options = {}, embedded = {
32
32
  processMode: 'cli',
33
33
  };
34
34
  }
35
+ function toSafeInvokeTraceOptions(options) {
36
+ const { collaborationAuth: _collaborationAuth, ...traceOptions } = options;
37
+ return traceOptions;
38
+ }
35
39
  /**
36
40
  * Internal runtime that delegates operations to a persistent host transport.
37
41
  *
@@ -60,7 +64,7 @@ class SuperDocRuntime {
60
64
  phase: 'start',
61
65
  operationId: operation.operationId,
62
66
  params,
63
- options,
67
+ options: toSafeInvokeTraceOptions(options),
64
68
  });
65
69
  try {
66
70
  const result = await this.transport.invoke(operation, params, options);
@@ -88,3 +92,4 @@ class SuperDocRuntime {
88
92
 
89
93
  exports.SuperDocRuntime = SuperDocRuntime;
90
94
  exports.resolveRuntimeProcess = resolveRuntimeProcess;
95
+ exports.toSafeInvokeTraceOptions = toSafeInvokeTraceOptions;
@@ -1,4 +1,4 @@
1
- import type { DocumentRuntimeKind, InvokeOptions, OperationParamSpec, OperationSpec, RuntimeInvoker, SuperDocClientOptions } from './transport-common.js';
1
+ import type { CollaborationAuth, DocumentRuntimeKind, DocOpenOptions, InvokeOptions, TransportInvokeOptions, OperationParamSpec, OperationSpec, RuntimeInvoker, SuperDocClientOptions } from './transport-common.js';
2
2
  type RuntimeProcessResolution = {
3
3
  hostBin: string;
4
4
  processMode: 'cli' | 'document';
@@ -8,6 +8,7 @@ type EmbeddedRuntimeResolvers = {
8
8
  documentHost: () => string;
9
9
  };
10
10
  export declare function resolveRuntimeProcess(options?: SuperDocClientOptions, embedded?: EmbeddedRuntimeResolvers): RuntimeProcessResolution;
11
+ export declare function toSafeInvokeTraceOptions(options: TransportInvokeOptions): InvokeOptions;
11
12
  /**
12
13
  * Internal runtime that delegates operations to a persistent host transport.
13
14
  *
@@ -19,6 +20,6 @@ export declare class SuperDocRuntime {
19
20
  constructor(options?: SuperDocClientOptions);
20
21
  connect(): Promise<void>;
21
22
  dispose(): Promise<void>;
22
- invoke<TData = unknown>(operation: OperationSpec, params?: Record<string, unknown>, options?: InvokeOptions): Promise<TData>;
23
+ invoke<TData = unknown>(operation: OperationSpec, params?: Record<string, unknown>, options?: TransportInvokeOptions): Promise<TData>;
23
24
  }
24
- export type { DocumentRuntimeKind, InvokeOptions, OperationParamSpec, OperationSpec, RuntimeInvoker, SuperDocClientOptions, };
25
+ export type { CollaborationAuth, DocumentRuntimeKind, DocOpenOptions, InvokeOptions, OperationParamSpec, OperationSpec, RuntimeInvoker, SuperDocClientOptions, };