@components-kit/open-workbook 0.1.4 → 0.1.6

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.
@@ -1,13 +1,13 @@
1
1
  import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import path from "node:path";
4
- import { BackupManager, BatchCompiler, DefaultPermissionPolicy, buildFormulaDependencyGraph, attachConflictGuidance, extractFormulaReferences, hashStable, LockManager, formatA1Address, parseA1Address, PlanManager, SnapshotManager, TaskRegistry, TemplateRegistry, TransactionManager, traceDependents, tracePrecedents } from "@components-kit/open-workbook-excel-core";
4
+ import { BackupManager, BatchCompiler, cellCount, DefaultPermissionPolicy, buildFormulaDependencyGraph, attachConflictGuidance, extractFormulaReferences, hashStable, LockManager, formatA1Address, parseA1Address, PlanManager, SnapshotManager, TaskRegistry, TemplateRegistry, TransactionManager, traceDependents, tracePrecedents } from "@components-kit/open-workbook-excel-core";
5
5
  import { makeRollbackConflict } from "@components-kit/open-workbook-excel-core";
6
6
  import { getToolCatalogSummary, PromptCatalog, ResourceCatalog, makeId, runtimeError } from "@components-kit/open-workbook-protocol";
7
7
  import { SessionRegistry } from "./session-registry.js";
8
8
  import { NativeFileBridge } from "./native-file-bridge.js";
9
9
  import { RuntimeStateStore } from "./state-store.js";
10
- const runtimeVersion = process.env.OPEN_WORKBOOK_VERSION ?? "0.1.4";
10
+ const runtimeVersion = process.env.OPEN_WORKBOOK_VERSION ?? "0.1.6";
11
11
  export class RuntimeService {
12
12
  sessions = new SessionRegistry();
13
13
  backups = new BackupManager();
@@ -21,10 +21,14 @@ export class RuntimeService {
21
21
  addinClients = new Map();
22
22
  regions = new Map();
23
23
  agents = new Map();
24
+ jobs = new Map();
24
25
  collabEvents = [];
25
26
  conflicts = [];
26
27
  conflictTelemetry = [];
27
28
  transactionQueue = Promise.resolve();
29
+ runtimeMutationActive = false;
30
+ runtimeMutationQueuedCount = 0;
31
+ cancelledQueuedTransactions = new Set();
28
32
  defaultAgentId = "agent_daemon";
29
33
  stateStore;
30
34
  fileBridge;
@@ -357,14 +361,70 @@ export class RuntimeService {
357
361
  return { ok: result.missingLockIds.length === 0, released: result.released, missingLockIds: result.missingLockIds };
358
362
  }
359
363
  listTransactions(workbookId) {
360
- return { ok: true, transactions: this.transactions.list(workbookId) };
364
+ return {
365
+ ok: true,
366
+ transactions: this.transactions.list(workbookId).map((transaction) => this.transactions.withQueueMetadata(transaction))
367
+ };
361
368
  }
362
369
  getTransaction(transactionId) {
363
370
  const transaction = this.transactions.get(transactionId);
364
371
  return transaction
365
- ? { ok: true, transaction }
372
+ ? { ok: true, transaction: this.transactions.withQueueMetadata(transaction) }
366
373
  : { ok: false, error: runtimeError("NOT_FOUND", `Transaction not found: ${transactionId}`, { retryable: false }) };
367
374
  }
375
+ async waitTransaction(transactionId, timeoutMs = 30_000, pollMs = 250) {
376
+ const started = Date.now();
377
+ while (true) {
378
+ const transaction = this.transactions.get(transactionId);
379
+ if (!transaction) {
380
+ return { ok: false, completed: false, error: runtimeError("NOT_FOUND", `Transaction not found: ${transactionId}`, { retryable: false }) };
381
+ }
382
+ const withMetadata = this.transactions.withQueueMetadata(transaction);
383
+ if (isTerminalTransactionStatus(withMetadata.status)) {
384
+ return { ok: true, completed: true, transaction: withMetadata };
385
+ }
386
+ if (Date.now() - started >= timeoutMs) {
387
+ return {
388
+ ok: true,
389
+ completed: false,
390
+ transaction: withMetadata,
391
+ error: runtimeError("TIMEOUT", `Timed out waiting for transaction ${transactionId}.`, { retryable: true })
392
+ };
393
+ }
394
+ await sleep(Math.max(25, pollMs));
395
+ }
396
+ }
397
+ cancelTransaction(transactionId) {
398
+ const transaction = this.transactions.get(transactionId);
399
+ if (!transaction) {
400
+ return { ok: false, error: runtimeError("NOT_FOUND", `Transaction not found: ${transactionId}`, { retryable: false }) };
401
+ }
402
+ if (transaction.status !== "queued") {
403
+ return {
404
+ ok: false,
405
+ transaction: this.transactions.withQueueMetadata(transaction),
406
+ error: runtimeError("OPERATION_FAILED", `Only queued transactions can be cancelled. Current status: ${transaction.status}.`, { retryable: false })
407
+ };
408
+ }
409
+ this.cancelledQueuedTransactions.add(transactionId);
410
+ const cancelled = this.transactions.markCancelled(transactionId);
411
+ this.recordCollabEvent({
412
+ type: "transaction.cancelled",
413
+ workbookId: cancelled.workbookId,
414
+ agentId: cancelled.agentId,
415
+ taskId: cancelled.taskId,
416
+ transactionId,
417
+ message: cancelled.progressMessage ?? "Queued transaction cancelled.",
418
+ details: { transaction: cancelled }
419
+ });
420
+ if (cancelled.taskId !== undefined) {
421
+ this.updateTask(cancelled.taskId, { status: "cancelled", errorMessage: cancelled.errorMessage });
422
+ }
423
+ else {
424
+ this.persistState();
425
+ }
426
+ return { ok: true, transaction: this.transactions.withQueueMetadata(cancelled) };
427
+ }
368
428
  previewTransactionRollback(transactionId) {
369
429
  const transaction = this.transactions.get(transactionId);
370
430
  if (!transaction) {
@@ -752,6 +812,10 @@ export class RuntimeService {
752
812
  this.lockLeasePolicy = normalizeLockLeasePolicy(snapshot.lockLeasePolicy);
753
813
  }
754
814
  this.transactions.load(snapshot.transactions);
815
+ this.jobs.clear();
816
+ for (const job of snapshot.jobs ?? []) {
817
+ this.jobs.set(job.jobId, { ...job, transactionIds: [...job.transactionIds], warnings: [...job.warnings] });
818
+ }
755
819
  this.conflicts.splice(0, this.conflicts.length, ...snapshot.conflicts.slice(-250));
756
820
  this.conflictTelemetry.splice(0, this.conflictTelemetry.length, ...(snapshot.conflictTelemetry ?? []).slice(-1_000));
757
821
  this.collabEvents.splice(0, this.collabEvents.length, ...snapshot.collaborationEvents.slice(-1_000));
@@ -817,6 +881,7 @@ export class RuntimeService {
817
881
  locks: this.locks.dump(),
818
882
  lockLeasePolicy: this.lockLeasePolicy,
819
883
  transactions: this.transactions.dump(),
884
+ jobs: [...this.jobs.values()].map((job) => ({ ...job, transactionIds: [...job.transactionIds], warnings: [...job.warnings] })),
820
885
  conflicts: this.conflicts.slice(-250),
821
886
  conflictTelemetry: this.conflictTelemetry.slice(-1_000),
822
887
  collaborationEvents: this.collabEvents.slice(-1_000),
@@ -1104,6 +1169,247 @@ export class RuntimeService {
1104
1169
  compileBatch(request) {
1105
1170
  return this.compiler.compile(request);
1106
1171
  }
1172
+ preflightBatch(request) {
1173
+ const compiled = this.compiler.compile(request);
1174
+ const estimatedPayloadBytes = Buffer.byteLength(JSON.stringify(request.operations), "utf8");
1175
+ const chunkPlan = planBatchChunks(request.operations);
1176
+ const warnings = [];
1177
+ const needsQueue = request.operations.length > batchDirectOperationThreshold() ||
1178
+ estimatedPayloadBytes > batchDirectPayloadThresholdBytes() ||
1179
+ compiled.estimatedCellsTouched > batchDirectCellThreshold() ||
1180
+ (chunkPlan.safeToAutoChunk && chunkPlan.chunksTotal > 1);
1181
+ let recommendedExecutionMode = needsQueue ? "submit" : "apply";
1182
+ if (needsQueue && chunkPlan.safeToAutoChunk && chunkPlan.chunksTotal > 1) {
1183
+ recommendedExecutionMode = "chunked_submit";
1184
+ warnings.push({
1185
+ code: "LARGE_BATCH_WILL_BE_CHUNKED",
1186
+ message: `Batch is large and can be safely split into ${chunkPlan.chunksTotal} queued chunks.`,
1187
+ details: { strategy: chunkPlan.strategy, chunkSize: chunkPlan.chunkSize }
1188
+ });
1189
+ }
1190
+ else if (needsQueue) {
1191
+ warnings.push({
1192
+ code: "LARGE_BATCH_SHOULD_BE_QUEUED",
1193
+ message: "Batch is large enough that it should be submitted to the serialized queue instead of applied synchronously.",
1194
+ details: {
1195
+ operationThreshold: batchDirectOperationThreshold(),
1196
+ payloadThresholdBytes: batchDirectPayloadThresholdBytes(),
1197
+ cellThreshold: batchDirectCellThreshold()
1198
+ }
1199
+ });
1200
+ }
1201
+ return {
1202
+ ok: true,
1203
+ workbookId: request.workbookId,
1204
+ operationCount: request.operations.length,
1205
+ estimatedCellsTouched: compiled.estimatedCellsTouched,
1206
+ estimatedPayloadBytes,
1207
+ destructiveLevel: compiled.destructiveLevel,
1208
+ recommendedExecutionMode,
1209
+ safeToAutoChunk: chunkPlan.safeToAutoChunk,
1210
+ chunkPlan: chunkPlan.strategy === "none" ? undefined : chunkPlan,
1211
+ warnings
1212
+ };
1213
+ }
1214
+ submitChunkedBatch(request, input = {}) {
1215
+ if (request.mode !== "apply") {
1216
+ return {
1217
+ ok: false,
1218
+ error: runtimeError("INVALID_ARGUMENT", "Only apply-mode batches can be submitted as chunked jobs.", { retryable: false })
1219
+ };
1220
+ }
1221
+ const preflight = this.preflightBatch(request);
1222
+ if (!preflight.safeToAutoChunk || preflight.chunkPlan === undefined || preflight.chunkPlan.chunksTotal <= 1) {
1223
+ const submitted = this.submitBatch(request);
1224
+ return {
1225
+ ok: submitted.ok,
1226
+ status: "queued",
1227
+ progressMessage: "Batch was not safely chunkable, so it was submitted as one queued transaction.",
1228
+ preflight,
1229
+ transactionIds: submitted.transactionId ? [submitted.transactionId] : [],
1230
+ transactions: [submitted]
1231
+ };
1232
+ }
1233
+ const chunks = chunkBatchOperations(request.operations);
1234
+ const now = new Date().toISOString();
1235
+ const compiled = this.compiler.compile(request);
1236
+ const agentId = request.agentId ?? this.defaultAgentId;
1237
+ const job = {
1238
+ jobId: makeId("job"),
1239
+ workbookId: request.workbookId,
1240
+ agentId,
1241
+ kind: preflight.chunkPlan.strategy === "split_style_entries" ? "style_chunked" : preflight.chunkPlan.strategy === "split_matrix_rows" ? "matrix_chunked" : "batch_chunked",
1242
+ status: "queued",
1243
+ goal: input.goal ?? request.progressMessage ?? "Apply chunked Excel batch",
1244
+ transactionIds: [],
1245
+ chunksTotal: chunks.length,
1246
+ chunksCompleted: 0,
1247
+ progressMessage: `Queued ${chunks.length} workbook update chunks.`,
1248
+ retryStrategy: input.retryStrategy ?? preflight.chunkPlan.strategy,
1249
+ destructiveLevel: compiled.destructiveLevel,
1250
+ warnings: preflight.warnings,
1251
+ queuedAt: now
1252
+ };
1253
+ if (request.taskId !== undefined) {
1254
+ job.taskId = request.taskId;
1255
+ }
1256
+ if (request.planId !== undefined) {
1257
+ job.planId = request.planId;
1258
+ }
1259
+ this.jobs.set(job.jobId, job);
1260
+ const transactions = chunks.map((chunk, index) => this.submitBatch({
1261
+ ...request,
1262
+ operations: chunk,
1263
+ retryStrategy: job.retryStrategy,
1264
+ chunksTotal: chunks.length,
1265
+ chunksCompleted: index,
1266
+ progressMessage: `Queued workbook update chunk ${index + 1} of ${chunks.length}.`
1267
+ }));
1268
+ job.transactionIds = transactions.map((transaction) => transaction.transactionId).filter(Boolean);
1269
+ this.refreshJob(job.jobId);
1270
+ this.persistState();
1271
+ return {
1272
+ ok: true,
1273
+ status: "queued",
1274
+ job: this.getJobRecord(job.jobId),
1275
+ jobId: job.jobId,
1276
+ preflight,
1277
+ retryStrategy: job.retryStrategy,
1278
+ chunksTotal: chunks.length,
1279
+ chunksCompleted: 0,
1280
+ transactionIds: job.transactionIds,
1281
+ transactions,
1282
+ progressMessage: `Batch is large, so Open Workbook queued it as job ${job.jobId} with ${chunks.length} chunks. Use excel.job.wait or excel.job.get for progress.`
1283
+ };
1284
+ }
1285
+ listJobs(workbookId) {
1286
+ return {
1287
+ ok: true,
1288
+ jobs: [...this.jobs.values()]
1289
+ .filter((job) => workbookId === undefined || job.workbookId === workbookId)
1290
+ .map((job) => this.refreshJob(job.jobId))
1291
+ .sort((a, b) => (b.finishedAt ?? b.startedAt ?? b.queuedAt).localeCompare(a.finishedAt ?? a.startedAt ?? a.queuedAt))
1292
+ };
1293
+ }
1294
+ getJob(jobId) {
1295
+ const job = this.getJobRecord(jobId);
1296
+ return job
1297
+ ? { ok: true, job }
1298
+ : { ok: false, error: runtimeError("NOT_FOUND", `Job not found: ${jobId}`, { retryable: false }) };
1299
+ }
1300
+ async waitJob(jobId, timeoutMs = 30_000, pollMs = 250) {
1301
+ const started = Date.now();
1302
+ while (true) {
1303
+ const job = this.getJobRecord(jobId);
1304
+ if (!job) {
1305
+ return { ok: false, completed: false, error: runtimeError("NOT_FOUND", `Job not found: ${jobId}`, { retryable: false }) };
1306
+ }
1307
+ if (isTerminalJobStatus(job.status)) {
1308
+ return { ok: true, completed: true, job };
1309
+ }
1310
+ if (Date.now() - started >= timeoutMs) {
1311
+ return {
1312
+ ok: true,
1313
+ completed: false,
1314
+ job,
1315
+ error: runtimeError("TIMEOUT", `Timed out waiting for job ${jobId}.`, { retryable: true })
1316
+ };
1317
+ }
1318
+ await sleep(Math.max(25, pollMs));
1319
+ }
1320
+ }
1321
+ cancelJob(jobId) {
1322
+ const job = this.getJobRecord(jobId);
1323
+ if (!job) {
1324
+ return { ok: false, error: runtimeError("NOT_FOUND", `Job not found: ${jobId}`, { retryable: false }) };
1325
+ }
1326
+ const cancelled = [];
1327
+ const skipped = [];
1328
+ for (const transactionId of job.transactionIds) {
1329
+ const transaction = this.transactions.get(transactionId);
1330
+ if (!transaction) {
1331
+ continue;
1332
+ }
1333
+ if (transaction.status === "queued") {
1334
+ const result = this.cancelTransaction(transactionId);
1335
+ if (result.ok && result.transaction) {
1336
+ cancelled.push(result.transaction);
1337
+ }
1338
+ }
1339
+ else if (!isTerminalTransactionStatus(transaction.status)) {
1340
+ skipped.push(this.transactions.withQueueMetadata(transaction));
1341
+ }
1342
+ }
1343
+ const refreshed = this.refreshJob(jobId);
1344
+ this.persistState();
1345
+ return {
1346
+ ok: skipped.length === 0,
1347
+ job: refreshed,
1348
+ cancelledTransactions: cancelled,
1349
+ skippedTransactions: skipped,
1350
+ progressMessage: skipped.length === 0
1351
+ ? `Cancelled queued work for job ${jobId}.`
1352
+ : `Cancelled queued chunks for job ${jobId}; ${skipped.length} chunk(s) were already applying and could not be cancelled.`
1353
+ };
1354
+ }
1355
+ getJobRecord(jobId) {
1356
+ const job = this.jobs.get(jobId);
1357
+ return job ? { ...this.refreshJob(jobId), transactionIds: [...job.transactionIds], warnings: [...job.warnings] } : undefined;
1358
+ }
1359
+ refreshJob(jobId) {
1360
+ const job = this.jobs.get(jobId);
1361
+ if (!job) {
1362
+ throw new Error(`Job not found: ${jobId}`);
1363
+ }
1364
+ const transactions = job.transactionIds.map((transactionId) => this.transactions.get(transactionId)).filter((transaction) => transaction !== undefined);
1365
+ const appliedCount = transactions.filter((transaction) => transaction.status === "applied").length;
1366
+ const terminalTransactions = transactions.filter((transaction) => isTerminalTransactionStatus(transaction.status));
1367
+ const failed = transactions.find((transaction) => transaction.status === "failed" || transaction.status === "blocked");
1368
+ const applying = transactions.some((transaction) => transaction.status === "applying");
1369
+ const queued = transactions.some((transaction) => transaction.status === "queued");
1370
+ const cancelledCount = transactions.filter((transaction) => transaction.status === "cancelled").length;
1371
+ let status = job.status;
1372
+ if (transactions.length === 0) {
1373
+ status = "queued";
1374
+ }
1375
+ else if (appliedCount === transactions.length) {
1376
+ status = "applied";
1377
+ }
1378
+ else if (cancelledCount === transactions.length) {
1379
+ status = "cancelled";
1380
+ }
1381
+ else if (terminalTransactions.length === transactions.length && appliedCount > 0) {
1382
+ status = "partially_applied";
1383
+ }
1384
+ else if (terminalTransactions.length === transactions.length) {
1385
+ status = failed !== undefined ? "failed" : "cancelled";
1386
+ }
1387
+ else if (failed !== undefined) {
1388
+ status = appliedCount > 0 ? "partially_applied" : "failed";
1389
+ }
1390
+ else if (appliedCount > 0 && terminalTransactions.length < transactions.length) {
1391
+ status = "partially_applied";
1392
+ }
1393
+ else if (applying) {
1394
+ status = "applying";
1395
+ }
1396
+ else if (queued) {
1397
+ status = "queued";
1398
+ }
1399
+ const now = new Date().toISOString();
1400
+ job.status = status;
1401
+ job.chunksCompleted = appliedCount;
1402
+ job.startedAt = job.startedAt ?? transactions.find((transaction) => transaction.startedAt !== undefined)?.startedAt;
1403
+ if (isTerminalJobStatus(status)) {
1404
+ job.finishedAt = job.finishedAt ?? now;
1405
+ }
1406
+ if (failed !== undefined) {
1407
+ job.errorCode = failed.errorCode;
1408
+ job.errorMessage = failed.errorMessage;
1409
+ }
1410
+ job.progressMessage = jobProgressMessage(job, transactions.length);
1411
+ return job;
1412
+ }
1107
1413
  async previewPlan(planId) {
1108
1414
  const preview = this.plans.previewPlan(planId);
1109
1415
  const client = this.getActiveAddinClient();
@@ -3929,6 +4235,151 @@ export class RuntimeService {
3929
4235
  this.plans.markApplyResult(planId, result);
3930
4236
  return { ...result, planId };
3931
4237
  }
4238
+ async previewRiskyEdit(input) {
4239
+ const goal = input.goal ?? input.reason ?? "Scoped risky edit";
4240
+ if (input.operations.length === 0) {
4241
+ return {
4242
+ ok: false,
4243
+ workflow: "excel.workflow.preview_risky_edit",
4244
+ applied: false,
4245
+ completedSteps: [],
4246
+ errorStep: "operations",
4247
+ error: runtimeError("INVALID_ARGUMENT", "previewRiskyEdit requires at least one scoped operation.", {
4248
+ retryable: false
4249
+ })
4250
+ };
4251
+ }
4252
+ const sparseWarnings = detectSparseOverwriteWarnings(input.operations);
4253
+ if (sparseWarnings.length > 0 && input.allowSparseOverwrite !== true) {
4254
+ return {
4255
+ ok: false,
4256
+ workflow: "excel.workflow.preview_risky_edit",
4257
+ applied: false,
4258
+ completedSteps: [],
4259
+ errorStep: "sparse_write_guard",
4260
+ warnings: sparseWarnings,
4261
+ error: runtimeError("INVALID_ARGUMENT", "Risky workflow blocked a sparse/null-padded range write. Use the smallest changed range, use clear_values_keep_format for explicit clearing, or pass allowSparseOverwrite when this broad overwrite is intentional.", {
4262
+ retryable: false,
4263
+ details: { warnings: sparseWarnings }
4264
+ })
4265
+ };
4266
+ }
4267
+ const ranges = input.ranges?.length ? input.ranges : snapshotRangesFromOperations(input.workbookId, input.operations);
4268
+ const before = await this.createWorkbookSnapshot({
4269
+ workbookId: input.workbookId,
4270
+ reason: `Before ${goal}`,
4271
+ ...(ranges.length > 0 ? { ranges } : {})
4272
+ });
4273
+ if (!before.ok || !("snapshot" in before)) {
4274
+ return {
4275
+ ok: false,
4276
+ workflow: "excel.workflow.preview_risky_edit",
4277
+ applied: false,
4278
+ completedSteps: [],
4279
+ errorStep: "before_snapshot",
4280
+ beforeSnapshotResult: before
4281
+ };
4282
+ }
4283
+ const planRequest = {
4284
+ workbookId: input.workbookId,
4285
+ goal,
4286
+ operations: input.operations,
4287
+ baseSnapshotId: before.snapshot.snapshotId
4288
+ };
4289
+ if (input.agentId !== undefined) {
4290
+ planRequest.agentId = input.agentId;
4291
+ }
4292
+ if (input.agentName !== undefined) {
4293
+ planRequest.agentName = input.agentName;
4294
+ }
4295
+ if (input.taskId !== undefined) {
4296
+ planRequest.taskId = input.taskId;
4297
+ }
4298
+ if (input.role !== undefined) {
4299
+ planRequest.role = input.role;
4300
+ }
4301
+ const plan = this.createPlan(planRequest);
4302
+ const planPreview = await this.previewPlan(plan.planId);
4303
+ const completedSteps = ["before_snapshot", "plan_create", "plan_preview"];
4304
+ if (input.apply === false) {
4305
+ return {
4306
+ ok: true,
4307
+ workflow: "excel.workflow.preview_risky_edit",
4308
+ applied: false,
4309
+ completedSteps,
4310
+ planId: plan.planId,
4311
+ beforeSnapshot: before.snapshot,
4312
+ planPreview,
4313
+ recovery: {
4314
+ beforeSnapshotId: before.snapshot.snapshotId,
4315
+ rollbackAvailable: false
4316
+ },
4317
+ nextSteps: ["Apply the previewed plan with excel.plan.apply, then capture an after snapshot, diff, and rollback preview."]
4318
+ };
4319
+ }
4320
+ const applyResult = await this.applyPlan(plan.planId, input.confirmationToken);
4321
+ completedSteps.push("plan_apply");
4322
+ if (!applyResult.ok) {
4323
+ return {
4324
+ ok: false,
4325
+ workflow: "excel.workflow.preview_risky_edit",
4326
+ applied: false,
4327
+ completedSteps,
4328
+ errorStep: "plan_apply",
4329
+ planId: plan.planId,
4330
+ transactionId: applyResult.transactionId,
4331
+ beforeSnapshot: before.snapshot,
4332
+ planPreview,
4333
+ applyResult,
4334
+ recovery: {
4335
+ beforeSnapshotId: before.snapshot.snapshotId,
4336
+ transactionId: applyResult.transactionId,
4337
+ rollbackAvailable: false
4338
+ }
4339
+ };
4340
+ }
4341
+ const after = await this.createWorkbookSnapshot({
4342
+ workbookId: input.workbookId,
4343
+ reason: `After ${goal}`,
4344
+ ranges: before.snapshot.affectedRanges
4345
+ });
4346
+ if (after.ok && "snapshot" in after) {
4347
+ completedSteps.push("after_snapshot");
4348
+ }
4349
+ const diff = after.ok && "snapshot" in after
4350
+ ? this.compareSnapshots(before.snapshot.snapshotId, after.snapshot.snapshotId)
4351
+ : undefined;
4352
+ if (diff?.ok) {
4353
+ completedSteps.push("snapshot_diff");
4354
+ }
4355
+ const rollbackPreview = applyResult.transactionId !== undefined
4356
+ ? this.previewTransactionRollback(applyResult.transactionId)
4357
+ : undefined;
4358
+ if (rollbackPreview !== undefined) {
4359
+ completedSteps.push("rollback_preview");
4360
+ }
4361
+ return {
4362
+ ok: Boolean(after.ok && diff?.ok),
4363
+ workflow: "excel.workflow.preview_risky_edit",
4364
+ applied: true,
4365
+ completedSteps,
4366
+ summary: riskyEditSummary(applyResult, diff, rollbackPreview),
4367
+ planId: plan.planId,
4368
+ transactionId: applyResult.transactionId,
4369
+ beforeSnapshot: before.snapshot,
4370
+ afterSnapshot: after.ok && "snapshot" in after ? after.snapshot : undefined,
4371
+ planPreview,
4372
+ applyResult,
4373
+ diff,
4374
+ rollbackPreview,
4375
+ recovery: {
4376
+ beforeSnapshotId: before.snapshot.snapshotId,
4377
+ afterSnapshotId: after.ok && "snapshot" in after ? after.snapshot.snapshotId : undefined,
4378
+ transactionId: applyResult.transactionId,
4379
+ rollbackAvailable: rollbackPreview?.rollbackAvailable ?? false
4380
+ }
4381
+ };
4382
+ }
3932
4383
  async rollbackPlan(planId, confirmationToken) {
3933
4384
  const plan = this.plans.getPlan(planId);
3934
4385
  if (!plan?.preview) {
@@ -3997,6 +4448,34 @@ export class RuntimeService {
3997
4448
  if (request.mode !== "apply") {
3998
4449
  return this.applyBatchDirect(request);
3999
4450
  }
4451
+ const returnQueuedProgress = this.isRuntimeMutationBusy();
4452
+ const scheduled = this.scheduleBatch(request);
4453
+ if (returnQueuedProgress) {
4454
+ void scheduled.promise.catch(() => undefined);
4455
+ return queuedOperationResult(this.transactions.withQueueMetadata(scheduled.transaction));
4456
+ }
4457
+ return scheduled.promise;
4458
+ }
4459
+ submitBatch(request) {
4460
+ if (request.mode !== "apply") {
4461
+ return {
4462
+ ok: false,
4463
+ error: runtimeError("INVALID_ARGUMENT", "Only apply-mode batches can be submitted to the mutation queue.", { retryable: false })
4464
+ };
4465
+ }
4466
+ const scheduled = this.scheduleBatch(request);
4467
+ void scheduled.promise.catch(() => undefined);
4468
+ const transaction = this.transactions.withQueueMetadata(scheduled.transaction);
4469
+ return {
4470
+ ok: true,
4471
+ transactionId: transaction.transactionId,
4472
+ status: transaction.status,
4473
+ queuePosition: transaction.queuePosition,
4474
+ progressMessage: transaction.progressMessage,
4475
+ transaction
4476
+ };
4477
+ }
4478
+ scheduleBatch(request) {
4000
4479
  const compiled = this.compiler.compile(request);
4001
4480
  const agentId = request.agentId ?? this.defaultAgentId;
4002
4481
  const scopes = scopesFromBatch(request.workbookId, request.operations);
@@ -4008,7 +4487,11 @@ export class RuntimeService {
4008
4487
  goal: request.operations.map((operation) => operation.reason || operation.kind).join("; ") || "Apply Excel batch",
4009
4488
  scopes,
4010
4489
  baseFingerprints: request.expectedTargetFingerprints ?? [],
4011
- destructiveLevel: compiled.destructiveLevel
4490
+ destructiveLevel: compiled.destructiveLevel,
4491
+ progressMessage: request.progressMessage,
4492
+ retryStrategy: request.retryStrategy,
4493
+ chunksTotal: request.chunksTotal,
4494
+ chunksCompleted: request.chunksCompleted
4012
4495
  });
4013
4496
  if (request.taskId !== undefined) {
4014
4497
  this.tasks.attachTransaction(request.taskId, transaction.transactionId);
@@ -4022,7 +4505,7 @@ export class RuntimeService {
4022
4505
  transactionId: transaction.transactionId,
4023
4506
  message: `Transaction queued: ${transaction.goal}`
4024
4507
  });
4025
- return this.enqueueTransaction(async () => {
4508
+ const promise = this.enqueueTransaction(transaction.transactionId, async () => {
4026
4509
  const lockResult = this.locks.acquire({
4027
4510
  workbookId: request.workbookId,
4028
4511
  ownerAgentId: agentId,
@@ -4055,6 +4538,8 @@ export class RuntimeService {
4055
4538
  return {
4056
4539
  ok: false,
4057
4540
  transactionId: transaction.transactionId,
4541
+ transactionStatus: "blocked",
4542
+ progressMessage: "Workbook mutation is blocked by an active lock.",
4058
4543
  taskId: request.taskId,
4059
4544
  agentId,
4060
4545
  rollbackAvailable: compiled.requiredBackups.length > 0,
@@ -4099,6 +4584,8 @@ export class RuntimeService {
4099
4584
  const enriched = {
4100
4585
  ...result,
4101
4586
  transactionId: transaction.transactionId,
4587
+ transactionStatus: result.ok ? "applied" : "failed",
4588
+ progressMessage: result.ok ? "Workbook mutation applied successfully." : (result.error?.message ?? "Workbook mutation failed."),
4102
4589
  agentId
4103
4590
  };
4104
4591
  if (request.taskId !== undefined) {
@@ -4140,6 +4627,82 @@ export class RuntimeService {
4140
4627
  }
4141
4628
  return enriched;
4142
4629
  }
4630
+ catch (error) {
4631
+ const message = error instanceof Error ? error.message : String(error);
4632
+ const code = /timed out|timeout/i.test(message) ? "TIMEOUT" : "TRANSACTION_FAILED";
4633
+ if (code === "TIMEOUT" && shouldRetryStyleBatch(request)) {
4634
+ const chunks = chunkOperationsForRetry(request.operations);
4635
+ const warnings = [
4636
+ {
4637
+ code: "RETRYING_SMALLER_BATCH",
4638
+ message: `Style batch timed out and was resubmitted as ${chunks.length} smaller queued chunks.`
4639
+ }
4640
+ ];
4641
+ this.transactions.markFailed(transaction.transactionId, code, message, warnings);
4642
+ if (request.taskId !== undefined) {
4643
+ this.updateTask(request.taskId, { status: "queued", currentStep: "Retrying style update in smaller chunks" });
4644
+ }
4645
+ this.recordCollabEvent({
4646
+ type: "transaction.failed",
4647
+ workbookId: request.workbookId,
4648
+ agentId,
4649
+ taskId: request.taskId,
4650
+ transactionId: transaction.transactionId,
4651
+ message: `${message}; retrying style update in smaller chunks.`
4652
+ });
4653
+ const retryTransactions = chunks.map((chunk, index) => this.submitBatch({
4654
+ ...request,
4655
+ operations: chunk,
4656
+ retryStrategy: "retry_timeout_split_style_entries",
4657
+ chunksTotal: chunks.length,
4658
+ chunksCompleted: index,
4659
+ progressMessage: `Retrying style update chunk ${index + 1} of ${chunks.length}.`
4660
+ }));
4661
+ return {
4662
+ ok: true,
4663
+ transactionId: transaction.transactionId,
4664
+ transactionStatus: "failed",
4665
+ progressMessage: `Style batch timed out, so Open Workbook queued ${chunks.length} smaller retry chunks.`,
4666
+ taskId: request.taskId,
4667
+ agentId,
4668
+ rollbackAvailable: false,
4669
+ backups: [],
4670
+ warnings,
4671
+ telemetry: { warningCount: warnings.length },
4672
+ data: {
4673
+ retryStrategy: "retry_timeout_split_style_entries",
4674
+ chunksTotal: chunks.length,
4675
+ retryTransactionIds: retryTransactions.map((retry) => retry.transactionId).filter(Boolean),
4676
+ retryTransactions
4677
+ }
4678
+ };
4679
+ }
4680
+ this.transactions.markFailed(transaction.transactionId, code, message);
4681
+ if (request.taskId !== undefined) {
4682
+ this.updateTask(request.taskId, { status: "failed", errorMessage: message });
4683
+ }
4684
+ this.recordCollabEvent({
4685
+ type: "transaction.failed",
4686
+ workbookId: request.workbookId,
4687
+ agentId,
4688
+ taskId: request.taskId,
4689
+ transactionId: transaction.transactionId,
4690
+ message
4691
+ });
4692
+ return {
4693
+ ok: false,
4694
+ transactionId: transaction.transactionId,
4695
+ transactionStatus: "failed",
4696
+ progressMessage: message,
4697
+ taskId: request.taskId,
4698
+ agentId,
4699
+ rollbackAvailable: compiled.requiredBackups.length > 0,
4700
+ backups: [],
4701
+ warnings: [],
4702
+ telemetry: { warningCount: 0 },
4703
+ error: runtimeError(code === "TIMEOUT" ? "TIMEOUT" : "OPERATION_FAILED", message, { retryable: code === "TIMEOUT" })
4704
+ };
4705
+ }
4143
4706
  finally {
4144
4707
  const releasedLocks = this.locks.release(lockResult.locks.map((lock) => lock.lockId));
4145
4708
  this.markConflictTelemetryClearedByLock(releasedLocks.map((lock) => lock.lockId));
@@ -4160,15 +4723,36 @@ export class RuntimeService {
4160
4723
  }
4161
4724
  }
4162
4725
  });
4726
+ return { transaction, promise };
4163
4727
  }
4164
- enqueueTransaction(work) {
4165
- return this.enqueueRuntimeMutation(work);
4728
+ enqueueTransaction(transactionId, work) {
4729
+ return this.enqueueRuntimeMutation(async () => {
4730
+ const transaction = this.transactions.get(transactionId);
4731
+ if (this.cancelledQueuedTransactions.delete(transactionId) || transaction?.status === "cancelled") {
4732
+ return cancelledOperationResult(transactionId);
4733
+ }
4734
+ return work();
4735
+ });
4166
4736
  }
4167
4737
  enqueueRuntimeMutation(work) {
4168
- const run = this.transactionQueue.then(work, work);
4738
+ this.runtimeMutationQueuedCount += 1;
4739
+ const run = this.transactionQueue.then(() => this.runQueuedRuntimeMutation(work), () => this.runQueuedRuntimeMutation(work));
4169
4740
  this.transactionQueue = run.then(() => undefined, () => undefined);
4170
4741
  return run;
4171
4742
  }
4743
+ async runQueuedRuntimeMutation(work) {
4744
+ this.runtimeMutationQueuedCount = Math.max(0, this.runtimeMutationQueuedCount - 1);
4745
+ this.runtimeMutationActive = true;
4746
+ try {
4747
+ return await work();
4748
+ }
4749
+ finally {
4750
+ this.runtimeMutationActive = false;
4751
+ }
4752
+ }
4753
+ isRuntimeMutationBusy() {
4754
+ return this.runtimeMutationActive || this.runtimeMutationQueuedCount > 0;
4755
+ }
4172
4756
  async applyDirectTransaction(input, work) {
4173
4757
  const agentId = input.agentId ?? this.defaultAgentId;
4174
4758
  const transaction = this.transactions.create({
@@ -5243,6 +5827,85 @@ function scopesFromBatch(workbookId, operations) {
5243
5827
  }
5244
5828
  return dedupeScopes(scopes.length > 0 ? scopes : [{ type: "workbook", workbookId }]);
5245
5829
  }
5830
+ function snapshotRangesFromOperations(workbookId, operations) {
5831
+ const ranges = [];
5832
+ for (const scope of scopesFromBatch(workbookId, operations)) {
5833
+ if (scope.type === "range" && scope.address !== undefined) {
5834
+ ranges.push({
5835
+ workbookId: scope.workbookId,
5836
+ sheetName: scope.sheetName,
5837
+ address: scope.address
5838
+ });
5839
+ }
5840
+ }
5841
+ const seen = new Set();
5842
+ return ranges.filter((range) => {
5843
+ const key = `${range.workbookId}:${range.sheetName}:${range.address}`;
5844
+ if (seen.has(key)) {
5845
+ return false;
5846
+ }
5847
+ seen.add(key);
5848
+ return true;
5849
+ });
5850
+ }
5851
+ function detectSparseOverwriteWarnings(operations) {
5852
+ const warnings = [];
5853
+ for (const operation of operations) {
5854
+ if (operation.kind !== "range.write_values") {
5855
+ continue;
5856
+ }
5857
+ const values = operation.values;
5858
+ if (!Array.isArray(values) || values.length === 0) {
5859
+ continue;
5860
+ }
5861
+ const matrixCells = values.reduce((sum, row) => sum + (Array.isArray(row) ? row.length : 0), 0);
5862
+ const nonEmptyCells = values.reduce((sum, row) => sum + (Array.isArray(row) ? row.filter((value) => value !== null && value !== undefined && value !== "").length : 0), 0);
5863
+ const targetCells = cellCount(operation.target.address);
5864
+ const touchedCells = Math.max(matrixCells, targetCells);
5865
+ if (touchedCells < 8 || nonEmptyCells === 0) {
5866
+ continue;
5867
+ }
5868
+ const nonEmptyRatio = nonEmptyCells / touchedCells;
5869
+ if (nonEmptyRatio <= 0.25 && touchedCells - nonEmptyCells >= 4) {
5870
+ warnings.push({
5871
+ code: "SPARSE_RANGE_WRITE_RISK",
5872
+ message: `Sparse range write to ${operation.target.sheetName}!${operation.target.address} has ${nonEmptyCells}/${touchedCells} non-empty cell(s). Use a smaller range or an explicit clear operation.`,
5873
+ target: operation.target,
5874
+ details: {
5875
+ operationId: operation.operationId,
5876
+ nonEmptyCells,
5877
+ touchedCells,
5878
+ nonEmptyRatio
5879
+ }
5880
+ });
5881
+ }
5882
+ }
5883
+ return warnings;
5884
+ }
5885
+ function riskyEditSummary(applyResult, diffResult, rollbackPreview) {
5886
+ const diff = diffResult?.diff;
5887
+ return {
5888
+ transactionId: applyResult.transactionId,
5889
+ backupIds: applyResult.backups,
5890
+ warningCount: applyResult.warnings.length,
5891
+ diff: diff
5892
+ ? {
5893
+ changedRanges: diff.changedRanges,
5894
+ cellsChanged: diff.cellsChanged,
5895
+ formulasChanged: diff.formulasChanged,
5896
+ stylesChanged: diff.stylesChanged,
5897
+ tablesChanged: diff.tablesChanged,
5898
+ sheetsChanged: diff.sheetsChanged,
5899
+ destructiveLevel: diff.destructiveLevel
5900
+ }
5901
+ : undefined,
5902
+ rollback: {
5903
+ previewed: rollbackPreview !== undefined,
5904
+ available: rollbackPreview?.rollbackAvailable ?? false,
5905
+ conflictCount: Array.isArray(rollbackPreview?.conflicts) ? rollbackPreview.conflicts.length : 0
5906
+ }
5907
+ };
5908
+ }
5246
5909
  function tableMutationScopes(request, ranges) {
5247
5910
  const scopes = ranges.map(rangeScope);
5248
5911
  if (request.tableName !== undefined) {
@@ -6062,6 +6725,201 @@ function telemetryBuckets(records, keySelector) {
6062
6725
  }))
6063
6726
  .sort((a, b) => b.count - a.count || b.lastSeenAt.localeCompare(a.lastSeenAt));
6064
6727
  }
6728
+ function isTerminalTransactionStatus(status) {
6729
+ return ["applied", "failed", "blocked", "rolled_back", "cancelled"].includes(status);
6730
+ }
6731
+ function isTerminalJobStatus(status) {
6732
+ return ["applied", "failed", "partially_applied", "cancelled"].includes(status);
6733
+ }
6734
+ function jobProgressMessage(job, knownTransactionCount) {
6735
+ switch (job.status) {
6736
+ case "queued":
6737
+ return `Workbook job is queued with ${knownTransactionCount || job.chunksTotal} chunk(s).`;
6738
+ case "applying":
6739
+ return `Workbook job is applying ${job.chunksCompleted} of ${job.chunksTotal} chunk(s).`;
6740
+ case "partially_applied":
6741
+ return job.errorMessage
6742
+ ? `Workbook job partially applied ${job.chunksCompleted} of ${job.chunksTotal} chunk(s): ${job.errorMessage}`
6743
+ : `Workbook job partially applied ${job.chunksCompleted} of ${job.chunksTotal} chunk(s).`;
6744
+ case "applied":
6745
+ return `Workbook job applied all ${job.chunksTotal} chunk(s).`;
6746
+ case "failed":
6747
+ return job.errorMessage ? `Workbook job failed: ${job.errorMessage}` : "Workbook job failed.";
6748
+ case "cancelled":
6749
+ return "Workbook job was cancelled before all chunks applied.";
6750
+ }
6751
+ }
6752
+ function planBatchChunks(operations) {
6753
+ const chunkedOperations = chunkBatchOperations(operations);
6754
+ if (chunkedOperations.length <= 1 || chunkedOperations.length === operations.length) {
6755
+ return {
6756
+ strategy: "none",
6757
+ chunksTotal: 1,
6758
+ chunkSize: operations.length,
6759
+ operationCount: operations.length,
6760
+ chunkedOperationKinds: [],
6761
+ safeToAutoChunk: false
6762
+ };
6763
+ }
6764
+ const allChunkable = operations.every((operation) => isStyleChunkableOperation(operation) || isMatrixChunkableOperation(operation));
6765
+ if (!allChunkable) {
6766
+ return {
6767
+ strategy: "none",
6768
+ chunksTotal: 1,
6769
+ chunkSize: operations.length,
6770
+ operationCount: operations.length,
6771
+ chunkedOperationKinds: [],
6772
+ safeToAutoChunk: false
6773
+ };
6774
+ }
6775
+ const hasStyle = operations.some(isStyleChunkableOperation);
6776
+ const hasMatrix = operations.some(isMatrixChunkableOperation);
6777
+ const chunkSize = hasMatrix ? matrixChunkRowCount() : styleBatchChunkSize();
6778
+ return {
6779
+ strategy: hasStyle && hasMatrix ? "mixed" : hasMatrix ? "split_matrix_rows" : "split_style_entries",
6780
+ chunksTotal: chunkedOperations.length,
6781
+ chunkSize,
6782
+ operationCount: operations.length,
6783
+ chunkedOperationKinds: [...new Set(operations.map((operation) => operation.kind))],
6784
+ safeToAutoChunk: true
6785
+ };
6786
+ }
6787
+ function chunkBatchOperations(operations) {
6788
+ if (operations.length === 0) {
6789
+ return [];
6790
+ }
6791
+ if (operations.every(isStyleChunkableOperation)) {
6792
+ return chunkArray(operations, styleBatchChunkSize());
6793
+ }
6794
+ if (!operations.every((operation) => isStyleChunkableOperation(operation) || isMatrixChunkableOperation(operation))) {
6795
+ return [operations];
6796
+ }
6797
+ const chunks = [];
6798
+ for (const operation of operations) {
6799
+ if (isMatrixChunkableOperation(operation)) {
6800
+ chunks.push(...chunkMatrixOperation(operation));
6801
+ }
6802
+ else {
6803
+ chunks.push([operation]);
6804
+ }
6805
+ }
6806
+ return chunks.length > 1 ? chunks : [operations];
6807
+ }
6808
+ function isStyleChunkableOperation(operation) {
6809
+ return operation.kind === "range.write_styles";
6810
+ }
6811
+ function isMatrixChunkableOperation(operation) {
6812
+ return operation.kind === "range.write_values" || operation.kind === "range.write_formulas" || operation.kind === "range.write_number_formats";
6813
+ }
6814
+ function chunkMatrixOperation(operation) {
6815
+ const matrix = matrixForOperation(operation);
6816
+ const rowCount = matrix.length;
6817
+ const chunkRows = matrixChunkRowCount();
6818
+ if (rowCount <= chunkRows) {
6819
+ return [[operation]];
6820
+ }
6821
+ const chunks = [];
6822
+ const parsed = parseA1Address(stripSheetName(operation.target.address));
6823
+ for (let start = 0; start < rowCount; start += chunkRows) {
6824
+ const rows = matrix.slice(start, start + chunkRows);
6825
+ const address = formatA1Address({
6826
+ ...parsed,
6827
+ startRow: parsed.startRow + start,
6828
+ endRow: parsed.startRow + start + rows.length - 1
6829
+ });
6830
+ chunks.push([cloneMatrixOperation(operation, rows, address)]);
6831
+ }
6832
+ return chunks;
6833
+ }
6834
+ function matrixForOperation(operation) {
6835
+ switch (operation.kind) {
6836
+ case "range.write_values":
6837
+ return operation.values;
6838
+ case "range.write_formulas":
6839
+ return operation.formulas;
6840
+ case "range.write_number_formats":
6841
+ return operation.numberFormat;
6842
+ }
6843
+ }
6844
+ function cloneMatrixOperation(operation, rows, address) {
6845
+ const target = { ...operation.target, address };
6846
+ switch (operation.kind) {
6847
+ case "range.write_values":
6848
+ return { ...operation, operationId: makeId("op"), target, values: rows };
6849
+ case "range.write_formulas":
6850
+ return { ...operation, operationId: makeId("op"), target, formulas: rows };
6851
+ case "range.write_number_formats":
6852
+ return { ...operation, operationId: makeId("op"), target, numberFormat: rows };
6853
+ }
6854
+ }
6855
+ function batchDirectOperationThreshold() {
6856
+ return positiveIntegerEnv("OPEN_WORKBOOK_BATCH_DIRECT_OPERATION_THRESHOLD", 25);
6857
+ }
6858
+ function batchDirectPayloadThresholdBytes() {
6859
+ return positiveIntegerEnv("OPEN_WORKBOOK_BATCH_DIRECT_PAYLOAD_BYTES", 512_000);
6860
+ }
6861
+ function batchDirectCellThreshold() {
6862
+ return positiveIntegerEnv("OPEN_WORKBOOK_BATCH_DIRECT_CELL_THRESHOLD", 50_000);
6863
+ }
6864
+ function styleBatchChunkSize() {
6865
+ return positiveIntegerEnv("OPEN_WORKBOOK_STYLE_BATCH_CHUNK_SIZE", 25);
6866
+ }
6867
+ function matrixChunkRowCount() {
6868
+ return positiveIntegerEnv("OPEN_WORKBOOK_MATRIX_CHUNK_ROWS", 500);
6869
+ }
6870
+ function positiveIntegerEnv(name, fallback) {
6871
+ const value = Number(process.env[name] ?? fallback);
6872
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
6873
+ }
6874
+ function chunkArray(items, size) {
6875
+ const chunks = [];
6876
+ for (let index = 0; index < items.length; index += size) {
6877
+ chunks.push(items.slice(index, index + size));
6878
+ }
6879
+ return chunks;
6880
+ }
6881
+ function sleep(ms) {
6882
+ return new Promise((resolve) => setTimeout(resolve, ms));
6883
+ }
6884
+ function cancelledOperationResult(transactionId) {
6885
+ return {
6886
+ ok: false,
6887
+ transactionId,
6888
+ transactionStatus: "cancelled",
6889
+ progressMessage: "Queued workbook mutation was cancelled before it reached Excel.",
6890
+ rollbackAvailable: false,
6891
+ backups: [],
6892
+ warnings: [],
6893
+ telemetry: { warningCount: 0 },
6894
+ error: runtimeError("TRANSACTION_CANCELLED", "Queued workbook mutation was cancelled before it reached Excel.", { retryable: false })
6895
+ };
6896
+ }
6897
+ function queuedOperationResult(transaction) {
6898
+ return {
6899
+ ok: true,
6900
+ transactionId: transaction.transactionId,
6901
+ transactionStatus: "queued",
6902
+ queuePosition: transaction.queuePosition,
6903
+ progressMessage: transaction.progressMessage ?? "Workbook mutation is queued and will apply when earlier workbook work finishes.",
6904
+ rollbackAvailable: false,
6905
+ backups: [],
6906
+ warnings: [],
6907
+ telemetry: { warningCount: 0 }
6908
+ };
6909
+ }
6910
+ function shouldRetryStyleBatch(request) {
6911
+ return request.retryStrategy !== "retry_timeout_split_style_entries"
6912
+ && request.operations.length > 1
6913
+ && request.operations.every((operation) => operation.kind === "range.write_styles");
6914
+ }
6915
+ function chunkOperationsForRetry(operations) {
6916
+ const chunkSize = Math.max(1, Math.ceil(operations.length / 2));
6917
+ const chunks = [];
6918
+ for (let index = 0; index < operations.length; index += chunkSize) {
6919
+ chunks.push(operations.slice(index, index + chunkSize));
6920
+ }
6921
+ return chunks;
6922
+ }
6065
6923
  function scopeTelemetryKey(scope) {
6066
6924
  switch (scope.type) {
6067
6925
  case "workbook":