libre-webui 0.39.0 → 0.40.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.
Files changed (198) hide show
  1. package/backend/dist/persistence/extensionTypes.d.ts +2 -0
  2. package/backend/dist/persistence/extensionTypes.d.ts.map +1 -1
  3. package/backend/dist/persistence/extensionTypes.js.map +1 -1
  4. package/backend/dist/persistence/postgresExtensionRepositories.d.ts.map +1 -1
  5. package/backend/dist/persistence/postgresExtensionRepositories.js +6 -0
  6. package/backend/dist/persistence/postgresExtensionRepositories.js.map +1 -1
  7. package/backend/dist/persistence/sqliteExtensionRepositories.d.ts.map +1 -1
  8. package/backend/dist/persistence/sqliteExtensionRepositories.js +7 -0
  9. package/backend/dist/persistence/sqliteExtensionRepositories.js.map +1 -1
  10. package/backend/dist/routes/email.d.ts.map +1 -1
  11. package/backend/dist/routes/email.js +51 -1
  12. package/backend/dist/routes/email.js.map +1 -1
  13. package/backend/dist/routes/tts.d.ts.map +1 -1
  14. package/backend/dist/routes/tts.js +6 -1
  15. package/backend/dist/routes/tts.js.map +1 -1
  16. package/backend/dist/services/agentCliService.d.ts.map +1 -1
  17. package/backend/dist/services/agentCliService.js +1 -0
  18. package/backend/dist/services/agentCliService.js.map +1 -1
  19. package/backend/dist/services/emailService.d.ts +17 -3
  20. package/backend/dist/services/emailService.d.ts.map +1 -1
  21. package/backend/dist/services/emailService.js +73 -11
  22. package/backend/dist/services/emailService.js.map +1 -1
  23. package/backend/dist/services/pluginCredentialsService.d.ts +3 -0
  24. package/backend/dist/services/pluginCredentialsService.d.ts.map +1 -1
  25. package/backend/dist/services/pluginCredentialsService.js +18 -5
  26. package/backend/dist/services/pluginCredentialsService.js.map +1 -1
  27. package/backend/dist/services/pluginService.d.ts +9 -0
  28. package/backend/dist/services/pluginService.d.ts.map +1 -1
  29. package/backend/dist/services/pluginService.js +31 -14
  30. package/backend/dist/services/pluginService.js.map +1 -1
  31. package/backend/dist/services/workRuntimeService.d.ts +5 -0
  32. package/backend/dist/services/workRuntimeService.d.ts.map +1 -1
  33. package/backend/dist/services/workRuntimeService.js +243 -84
  34. package/backend/dist/services/workRuntimeService.js.map +1 -1
  35. package/backend/dist/utils/emailMarkdown.js +3 -3
  36. package/backend/dist/utils/emailMarkdown.js.map +1 -1
  37. package/backend/dist/utils/pluginDefinitionTrust.d.ts +7 -0
  38. package/backend/dist/utils/pluginDefinitionTrust.d.ts.map +1 -1
  39. package/backend/dist/utils/pluginDefinitionTrust.js +49 -1
  40. package/backend/dist/utils/pluginDefinitionTrust.js.map +1 -1
  41. package/backend/package.json +1 -1
  42. package/frontend/dist/artifact-runtime/.build-fingerprint +1 -1
  43. package/frontend/dist/artifact-runtime/mermaid.js +366 -366
  44. package/frontend/dist/assets/index-DLfU_qNT.js +75 -0
  45. package/frontend/dist/assets/index-DtS3qhdq.css +2 -0
  46. package/frontend/dist/assets/xterm-B1njb2Cr.css +1 -0
  47. package/frontend/dist/index.html +27 -26
  48. package/frontend/dist/js/ArtifactContainer-BjzltfdU.js +1 -0
  49. package/frontend/dist/js/{ArtifactDemoPage-Ddpy2O_v.js → ArtifactDemoPage-DWua6xj9.js} +1 -1
  50. package/frontend/dist/js/{ArtifactSandboxFrame-CdsAqv8H.js → ArtifactSandboxFrame-CR9ghP0A.js} +1 -1
  51. package/frontend/dist/js/ArtifactSlideOutPanel-DX2nL3AS.js +1 -0
  52. package/frontend/dist/js/AutomationModal-DZe-C2jN.js +1 -0
  53. package/frontend/dist/js/AutomationsPage-CrCcnTyk.js +1 -0
  54. package/frontend/dist/js/AvatarUpload-nPS0QHod.js +1 -0
  55. package/frontend/dist/js/Button-Dh2UJA-B.js +1 -0
  56. package/frontend/dist/js/CalendarPage-D2WiWglM.js +1 -0
  57. package/frontend/dist/js/ChannelsPage-COp2T8FQ.js +1 -0
  58. package/frontend/dist/js/ChatPage-CUfAlpdt.js +14 -0
  59. package/frontend/dist/js/ConnectModels-emc3dv4A.js +1 -0
  60. package/frontend/dist/js/EvaluationsPage-CbyeDkz_.js +2 -0
  61. package/frontend/dist/js/FirstTimeSetup-C4ffQRMN.js +1 -0
  62. package/frontend/dist/js/GalleryPage-D4Ci_3dv.js +1 -0
  63. package/frontend/dist/js/HomePage-C3IhEERN.js +1 -0
  64. package/frontend/dist/js/LanguageSwitcher-BQqDJPwm.js +1 -0
  65. package/frontend/dist/js/ModelManagerModals-DhY8Rme6.js +1 -0
  66. package/frontend/dist/js/NotesPage-j812I1BN.js +3 -0
  67. package/frontend/dist/js/PasswordStrengthMeter-BSEtQBJp.js +1 -0
  68. package/frontend/dist/js/PersonasPage--YWoThqF.js +13 -0
  69. package/frontend/dist/js/PluginUsagePage-CZiMdmjI.js +1 -0
  70. package/frontend/dist/js/{RichMessageContent-BfYbVBFz.js → RichMessageContent-BLI0hhzR.js} +2 -2
  71. package/frontend/dist/js/RichMessageContent-BXv5baJ_.js +1 -0
  72. package/frontend/dist/js/SettingsModal-C6aVzSI-.js +19 -0
  73. package/frontend/dist/js/SettingsToggle-CkLLtFbz.js +1 -0
  74. package/frontend/dist/js/ShareDialog-DQskc17d.js +1 -0
  75. package/frontend/dist/js/StrandsPage-DENhVKcz.js +1 -0
  76. package/frontend/dist/js/SystemPage-C86ypXG4.js +1 -0
  77. package/frontend/dist/js/UserManagementPanel-D1CS0KPn.js +1 -0
  78. package/frontend/dist/js/WhatsNewModal-B31A_UQY.js +1 -0
  79. package/frontend/dist/js/WorkPage-DEXBL5J7.js +7 -0
  80. package/frontend/dist/js/api-DRAKhdz7.js +1 -0
  81. package/frontend/dist/js/{api-BrHW-VGd.js → api-Dcz1G4Ib.js} +4 -2
  82. package/frontend/dist/js/{appNavigation-B6xYIgLi.js → appNavigation-C5A9BF_G.js} +1 -1
  83. package/frontend/dist/js/appStore-0_qlwS8f.js +2 -0
  84. package/frontend/dist/js/appVersion-D5lk_2zb.js +1 -0
  85. package/frontend/dist/js/ar-BZntyucf.js +2 -0
  86. package/frontend/dist/js/authStore-C4dzH1NY.js +1 -0
  87. package/frontend/dist/js/{authStore-DjAsRpNL.js → authStore-COgKeTRv.js} +3 -3
  88. package/frontend/dist/js/bn-BDCaFQu-.js +2 -0
  89. package/frontend/dist/js/chatStore-CVTbkZyb.js +3 -0
  90. package/frontend/dist/js/{client-DppLngAs.js → client-jaxZzn-I.js} +2 -2
  91. package/frontend/dist/js/{composerStyles-C83jssgZ.js → composerStyles-BEx0XAp6.js} +1 -1
  92. package/frontend/dist/js/{config-4_s8hdqO.js → config-Da1yh9sC.js} +1 -1
  93. package/frontend/dist/js/confirmStore-CKkUtP9c.js +1 -0
  94. package/frontend/dist/js/cs-BCmN--jQ.js +2 -0
  95. package/frontend/dist/js/da-B895dAyV.js +2 -0
  96. package/frontend/dist/js/de-B0ZhC0dm.js +2 -0
  97. package/frontend/dist/js/es-D7eaw4_W.js +2 -0
  98. package/frontend/dist/js/{evaluationsApi-DyuW7IJ_.js → evaluationsApi-BwaxBVpl.js} +1 -1
  99. package/frontend/dist/js/fr-DsB0gLW4.js +2 -0
  100. package/frontend/dist/js/hi-CLeU3ung.js +2 -0
  101. package/frontend/dist/js/id-Cdq6tOTi.js +2 -0
  102. package/frontend/dist/js/is-DJ1tHaAT.js +2 -0
  103. package/frontend/dist/js/it-9K1R4CeT.js +2 -0
  104. package/frontend/dist/js/ja-BYP_3M5M.js +2 -0
  105. package/frontend/dist/js/ko-CNtjlaDm.js +2 -0
  106. package/frontend/dist/js/liveAnnouncerStore-B6mS-JWC.js +1 -0
  107. package/frontend/dist/js/messageCodeStyles-D3cWF9XO.js +2 -0
  108. package/frontend/dist/js/ms-A_IVO_qt.js +2 -0
  109. package/frontend/dist/js/nl-CrKWO3ea.js +2 -0
  110. package/frontend/dist/js/pl-BrpSuyDP.js +2 -0
  111. package/frontend/dist/js/pt-BHbpB5BM.js +2 -0
  112. package/frontend/dist/js/ru-DzLsS9zY.js +2 -0
  113. package/frontend/dist/js/{strandsApi-BFbeEpAY.js → strandsApi-COxSPh_5.js} +1 -1
  114. package/frontend/dist/js/sv-VU2CjzNj.js +2 -0
  115. package/frontend/dist/js/{teamEventStream-DsAS6P4m.js → teamEventStream-RNJYm84u.js} +1 -1
  116. package/frontend/dist/js/th-DV2hNXXc.js +2 -0
  117. package/frontend/dist/js/tr-DvVZ9jxF.js +2 -0
  118. package/frontend/dist/js/ui-CI0OdG9q.js +2 -0
  119. package/frontend/dist/js/ui-vendor-6S05F84J.js +1 -0
  120. package/frontend/dist/js/uk-MFFDRo2m.js +2 -0
  121. package/frontend/dist/js/useChat-BSkqxHnH.js +13 -0
  122. package/frontend/dist/js/useDictation-D9whH12W.js +1 -0
  123. package/frontend/dist/js/vi-CfZ-U8Cw.js +2 -0
  124. package/frontend/dist/js/{work-CdbmBok5.js → work-C7MYlv5S.js} +1 -1
  125. package/frontend/dist/js/{workModels-BM2Zyiba.js → workAnnouncements-CAsI8P7c.js} +1 -1
  126. package/frontend/dist/js/workApi-BJfbaV6k.js +1 -0
  127. package/frontend/dist/js/{workApi-UPxumrEF.js → workApi-Ck8nTlJn.js} +1 -1
  128. package/frontend/dist/js/zh-CTl_raYa.js +2 -0
  129. package/package.json +3 -2
  130. package/plugins/codex-oauth.json +1 -0
  131. package/frontend/dist/assets/index-D99DS5c6.js +0 -57
  132. package/frontend/dist/assets/index-Dz1JufgZ.css +0 -1
  133. package/frontend/dist/assets/xterm-B8QFf3JU.css +0 -1
  134. package/frontend/dist/js/ArtifactContainer-Cn7X9HGm.js +0 -1
  135. package/frontend/dist/js/ArtifactSlideOutPanel-cVd9X80o.js +0 -1
  136. package/frontend/dist/js/AutomationModal-B4UaWjkk.js +0 -1
  137. package/frontend/dist/js/AutomationsPage-huCrvLUp.js +0 -1
  138. package/frontend/dist/js/AvatarUpload-CG-w6Mpt.js +0 -1
  139. package/frontend/dist/js/Button-j68Q8KVh.js +0 -1
  140. package/frontend/dist/js/CalendarPage-CZXT1w99.js +0 -1
  141. package/frontend/dist/js/ChannelsPage-DbtdHnSL.js +0 -1
  142. package/frontend/dist/js/ChatPage-BZE65kiq.js +0 -14
  143. package/frontend/dist/js/ConnectModels-DD4bLgdq.js +0 -1
  144. package/frontend/dist/js/EvaluationsPage-Dq53LaXd.js +0 -2
  145. package/frontend/dist/js/FirstTimeSetup-BoJIfuQT.js +0 -1
  146. package/frontend/dist/js/GalleryPage-DKwqeB2A.js +0 -1
  147. package/frontend/dist/js/HomePage-C8bCJfls.js +0 -1
  148. package/frontend/dist/js/LanguageSwitcher-B_YyU5JG.js +0 -1
  149. package/frontend/dist/js/ModelManagerModals-BzjZ-QwL.js +0 -1
  150. package/frontend/dist/js/NotesPage-CnaAp_w_.js +0 -3
  151. package/frontend/dist/js/PasswordStrengthMeter-DI4RDv67.js +0 -1
  152. package/frontend/dist/js/PersonasPage-CH3GUKph.js +0 -13
  153. package/frontend/dist/js/PluginUsagePage-d54K2gAj.js +0 -1
  154. package/frontend/dist/js/RichMessageContent-CDxxs3Tt.js +0 -1
  155. package/frontend/dist/js/SettingsModal-BvtJcvow.js +0 -19
  156. package/frontend/dist/js/SettingsToggle-DrJ0Bapi.js +0 -1
  157. package/frontend/dist/js/ShareDialog-DaSxkTKS.js +0 -1
  158. package/frontend/dist/js/StrandsPage-BQwz0mWq.js +0 -1
  159. package/frontend/dist/js/SystemPage-BqNZoFxR.js +0 -1
  160. package/frontend/dist/js/UserManagementPanel-C3T1ReJJ.js +0 -1
  161. package/frontend/dist/js/WhatsNewModal-CLcU2Ugk.js +0 -1
  162. package/frontend/dist/js/WorkPage-BpjGn--1.js +0 -8
  163. package/frontend/dist/js/api-K_hhqPdx.js +0 -1
  164. package/frontend/dist/js/appStore-DkRdQQeY.js +0 -2
  165. package/frontend/dist/js/appVersion-BVtSqLVj.js +0 -1
  166. package/frontend/dist/js/ar-XTZGEtBr.js +0 -2
  167. package/frontend/dist/js/authStore-DBS6Kbvh.js +0 -1
  168. package/frontend/dist/js/bn-JiK7ZWmy.js +0 -2
  169. package/frontend/dist/js/chatStore-DpVxt9VX.js +0 -3
  170. package/frontend/dist/js/cs-D8ju18CO.js +0 -2
  171. package/frontend/dist/js/da-BJWcAoUc.js +0 -2
  172. package/frontend/dist/js/de-CLJLmy69.js +0 -2
  173. package/frontend/dist/js/es-BvqHEL-3.js +0 -2
  174. package/frontend/dist/js/fr-B8ZoZomI.js +0 -2
  175. package/frontend/dist/js/goober.modern-BaHbaW3_.js +0 -1
  176. package/frontend/dist/js/hi-AzJLALo-.js +0 -2
  177. package/frontend/dist/js/id-DZYGQhvC.js +0 -2
  178. package/frontend/dist/js/is-Da_kvtzW.js +0 -2
  179. package/frontend/dist/js/it-B90zSlqn.js +0 -2
  180. package/frontend/dist/js/ja-DU_K1GOr.js +0 -2
  181. package/frontend/dist/js/ko-DicfoO-e.js +0 -2
  182. package/frontend/dist/js/messageCodeStyles-CuQPkfEK.js +0 -2
  183. package/frontend/dist/js/ms-CfH7wjUY.js +0 -2
  184. package/frontend/dist/js/nl-ELlOz-B3.js +0 -2
  185. package/frontend/dist/js/pl-33dayyUk.js +0 -2
  186. package/frontend/dist/js/pt-aIXSQPCT.js +0 -2
  187. package/frontend/dist/js/ru-BSvGtBtW.js +0 -2
  188. package/frontend/dist/js/sv-B7KOz81W.js +0 -2
  189. package/frontend/dist/js/th-DgpjGxvA.js +0 -2
  190. package/frontend/dist/js/tr-BQTbl2h-.js +0 -2
  191. package/frontend/dist/js/ui-Aq8MP7Ph.js +0 -2
  192. package/frontend/dist/js/ui-vendor-DVTj_sKN.js +0 -1
  193. package/frontend/dist/js/uk-JLymloot.js +0 -2
  194. package/frontend/dist/js/useChat-D3ZAGz70.js +0 -10
  195. package/frontend/dist/js/useDictation-B6l8QzeM.js +0 -1
  196. package/frontend/dist/js/vi-BXdLOJ8q.js +0 -2
  197. package/frontend/dist/js/workApi-D1xtiG3Q.js +0 -1
  198. package/frontend/dist/js/zh-gdUf9sXa.js +0 -2
@@ -346,6 +346,9 @@ export const acquireSharedRuntimeLeaseWithWait = async (attempt, options) => {
346
346
  };
347
347
  /** How long a preview may sit at `starting` before it counts as abandoned. */
348
348
  const PREVIEW_START_GRACE_MS = 5 * 60000;
349
+ // Only this explicit protocol exempts a runtime hold from idle cleanup. Older
350
+ // generic members and unrecognized members remain protected during upgrades.
351
+ const isPreviewOnlyPresence = (member) => /^(?:standalone|app-external|app-embedded|external-worker)-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:preview-only$/.test(member);
349
352
  export class WorkRuntimeService {
350
353
  constructor(driver = new DockerWorkRuntimeDriver()) {
351
354
  this.image = config.image;
@@ -365,6 +368,7 @@ export class WorkRuntimeService {
365
368
  this.activeCommands = new Set();
366
369
  this.lastRuntimeUnavailableReason = null;
367
370
  this.runtimeLeases = new Map();
371
+ this.runtimePresenceTails = new Map();
368
372
  this.previewLeaseReleases = new Map();
369
373
  this.previewUsageReleases = new Map();
370
374
  this.terminalHolds = new Map();
@@ -621,6 +625,15 @@ export class WorkRuntimeService {
621
625
  throw new WorkRuntimeError('This Work runtime lease belongs to a different administrator.', 409, 'WORK_RUNTIME_LEASE_CONFLICT');
622
626
  }
623
627
  existing.holders += 1;
628
+ try {
629
+ // Publish protection before a helper or run can enter the lifecycle
630
+ // lock, even when its only previous holder was an idle preview.
631
+ await this.refreshRuntimePresence(task, existing);
632
+ }
633
+ catch (error) {
634
+ this.releaseRuntimeLease(task.id, task.userId);
635
+ throw error;
636
+ }
624
637
  }
625
638
  else {
626
639
  const perUser = [...this.runtimeLeases.values()].filter(lease => lease.userId === task.userId).length;
@@ -666,8 +679,10 @@ export class WorkRuntimeService {
666
679
  throw new Error('lease ownership was lost');
667
680
  })
668
681
  .catch(error => {
669
- if (runtimeLease.sharedLeaseLost)
682
+ if (runtimeLease.sharedLeaseLost ||
683
+ this.runtimeLeases.get(task.id) !== runtimeLease) {
670
684
  return;
685
+ }
671
686
  runtimeLease.sharedLeaseLost = true;
672
687
  logger.error(`Shared Work runtime lease was lost for task ${task.id}; stopping its sandbox:`, error);
673
688
  void this.driver
@@ -676,7 +691,7 @@ export class WorkRuntimeService {
676
691
  });
677
692
  }, 20000);
678
693
  runtimeLease.sharedLeaseTimer.unref?.();
679
- const refreshPresence = () => withCoordinationTimeout(getCoordinator().setPresence(`work-task-active:${task.id}`, this.activityMemberId, 30000), SHARED_COORDINATION_OPERATION_TIMEOUT_MS);
694
+ const refreshPresence = (reconcilePreview = false) => this.refreshRuntimePresence(task, runtimeLease, reconcilePreview);
680
695
  try {
681
696
  await waitForAbortSignal(refreshPresence(), signal);
682
697
  }
@@ -688,7 +703,7 @@ export class WorkRuntimeService {
688
703
  throw error;
689
704
  }
690
705
  runtimeLease.presenceTimer = setInterval(() => {
691
- void refreshPresence().catch(error => logger.warn(`Could not refresh Work activity for ${task.id}:`, error));
706
+ void refreshPresence(true).catch(error => logger.warn(`Could not refresh Work activity for ${task.id}:`, error));
692
707
  }, 10000);
693
708
  runtimeLease.presenceTimer.unref?.();
694
709
  }
@@ -710,6 +725,7 @@ export class WorkRuntimeService {
710
725
  lease.holders -= 1;
711
726
  if (!force && lease.holders > 0)
712
727
  return;
728
+ lease.released = true;
713
729
  if (lease.presenceTimer)
714
730
  clearInterval(lease.presenceTimer);
715
731
  if (lease.sharedLeaseTimer)
@@ -717,10 +733,78 @@ export class WorkRuntimeService {
717
733
  this.runtimeLeases.delete(taskId);
718
734
  void lease.sharedLease?.release().catch(() => false);
719
735
  if (getPlatformRuntimeConfig().mode === 'team') {
720
- void getCoordinator()
721
- .clearPresence(`work-task-active:${taskId}`, this.activityMemberId)
722
- .catch(error => logger.warn(`Could not clear Work activity for ${taskId}:`, error));
736
+ const clear = async () => {
737
+ await withCoordinationTimeout(getCoordinator().clearPresence(`work-task-active:${taskId}`, this.activityMemberId), SHARED_COORDINATION_OPERATION_TIMEOUT_MS);
738
+ await withCoordinationTimeout(getCoordinator().clearPresence(`work-task-active:${taskId}`, `${this.activityMemberId}:preview-only`), SHARED_COORDINATION_OPERATION_TIMEOUT_MS);
739
+ };
740
+ lease.presenceTail = this.queueRuntimePresence(taskId, clear);
741
+ void lease.presenceTail.catch(error => logger.warn(`Could not clear Work activity for ${taskId}:`, error));
742
+ }
743
+ }
744
+ refreshRuntimePresence(task, lease, reconcilePreview = false) {
745
+ if (getPlatformRuntimeConfig().mode !== 'team')
746
+ return Promise.resolve();
747
+ const refresh = async () => {
748
+ if (lease.released)
749
+ return;
750
+ if (reconcilePreview && this.previewLeaseReleases.has(task.id)) {
751
+ const generation = lease.previewGeneration;
752
+ const release = this.previewLeaseReleases.get(task.id);
753
+ const row = await getWorkPersistence().findTask(task.id, task.userId);
754
+ // A stopped-state snapshot must not release a preview reopened while
755
+ // this database read was in flight. Startup advances the generation
756
+ // under the lifecycle lock before changing its persisted state.
757
+ if ((!row || row.preview_status === 'stopped') &&
758
+ lease.previewGeneration === generation &&
759
+ this.previewLeaseReleases.get(task.id) === release) {
760
+ this.releasePreviewLease(task.id);
761
+ }
762
+ }
763
+ if (lease.released)
764
+ return;
765
+ const activeMember = this.activityMemberId;
766
+ const previewMember = `${activeMember}:preview-only`;
767
+ const previewOnly = lease.holders === 1 && this.previewLeaseReleases.has(task.id);
768
+ // Set the new member before clearing the old one, and serialize all
769
+ // transitions so a racing heartbeat cannot erase a newly active hold.
770
+ await withCoordinationTimeout(getCoordinator().setPresence(`work-task-active:${task.id}`, previewOnly ? previewMember : activeMember, 30000), SHARED_COORDINATION_OPERATION_TIMEOUT_MS);
771
+ await withCoordinationTimeout(getCoordinator().clearPresence(`work-task-active:${task.id}`, previewOnly ? activeMember : previewMember), SHARED_COORDINATION_OPERATION_TIMEOUT_MS);
772
+ };
773
+ const pending = this.queueRuntimePresence(task.id, refresh);
774
+ lease.presenceTail = pending;
775
+ return pending;
776
+ }
777
+ queueRuntimePresence(taskId, operation) {
778
+ // Include releases and reacquisitions in one queue: an old lease's delayed
779
+ // clear must finish before a new lease publishes the same process member.
780
+ const pending = (this.runtimePresenceTails.get(taskId) ?? Promise.resolve())
781
+ .catch(() => undefined)
782
+ .then(operation);
783
+ this.runtimePresenceTails.set(taskId, pending);
784
+ void pending
785
+ .finally(() => {
786
+ if (this.runtimePresenceTails.get(taskId) === pending) {
787
+ this.runtimePresenceTails.delete(taskId);
788
+ }
789
+ })
790
+ .catch(() => undefined);
791
+ return pending;
792
+ }
793
+ hasLocalRuntimeActivity(taskId) {
794
+ return (this.activeCommands.has(taskId) ||
795
+ (this.terminalHolds.get(taskId) ?? 0) > 0 ||
796
+ (this.screenHolds.get(taskId) ?? 0) > 0 ||
797
+ (this.runtimeLeases.get(taskId)?.holders ?? 0) >
798
+ (this.previewLeaseReleases.has(taskId) ? 1 : 0));
799
+ }
800
+ async hasRuntimeActivity(taskId) {
801
+ if (this.hasLocalRuntimeActivity(taskId))
802
+ return true;
803
+ if (getPlatformRuntimeConfig().mode === 'team' &&
804
+ (await withCoordinationTimeout(getCoordinator().listPresence(`work-task-active:${taskId}`), SHARED_COORDINATION_OPERATION_TIMEOUT_MS)).some(member => !isPreviewOnlyPresence(member))) {
805
+ return true;
723
806
  }
807
+ return (await workUsageService.list(taskId)).some(entry => entry.kind !== 'preview');
724
808
  }
725
809
  assertRuntimeLease(task) {
726
810
  const lease = this.runtimeLeases.get(task.id);
@@ -866,7 +950,9 @@ export class WorkRuntimeService {
866
950
  releasePresence();
867
951
  try {
868
952
  if (remaining <= 0 && !this.shuttingDown) {
869
- await this.withLifecycleLock(task.id, (_assertHeld, signal) => this.stopContainerIfIdleWithLock(task, signal));
953
+ await this.withLifecycleLock(task.id, (_assertHeld, signal) => this.stopContainerIfIdleWithLock(task, signal, {
954
+ ownsRuntimeLease: false,
955
+ }));
870
956
  }
871
957
  }
872
958
  catch (error) {
@@ -1164,19 +1250,7 @@ export class WorkRuntimeService {
1164
1250
  // Containers without a task row are startup reconciliation's business.
1165
1251
  if (!task)
1166
1252
  continue;
1167
- if (getPlatformRuntimeConfig().mode === 'team' &&
1168
- (await getCoordinator().listPresence(`work-task-active:${task.id}`))
1169
- .length > 0) {
1170
- continue;
1171
- }
1172
- const busy = this.activeCommands.has(task.id) ||
1173
- (this.terminalHolds.get(task.id) ?? 0) > 0 ||
1174
- (this.screenHolds.get(task.id) ?? 0) > 0 ||
1175
- (this.runtimeLeases.has(task.id) &&
1176
- !this.previewLeaseReleases.has(task.id)) ||
1177
- // Holds from other processes: terminals, screens and commands
1178
- // record themselves in the usage registry.
1179
- (await workUsageService.list(task.id)).some(entry => entry.kind !== 'preview');
1253
+ const busy = await this.hasRuntimeActivity(task.id);
1180
1254
  const sharedActivity = getPlatformRuntimeConfig().mode === 'team'
1181
1255
  ? await getCoordinator().getCache(`work-task-activity:${task.id}`)
1182
1256
  : null;
@@ -1190,14 +1264,29 @@ export class WorkRuntimeService {
1190
1264
  if (idleAfterMs <= 0 || now - lastActivity < idleAfterMs)
1191
1265
  continue;
1192
1266
  try {
1193
- if (this.previewLeaseReleases.has(task.id)) {
1194
- await this.stopPreview(task, {
1195
- onStopped: () => workTaskService.updatePreview(task.id, 'stopped'),
1196
- });
1197
- }
1198
- else {
1199
- await this.stopContainer(task);
1200
- }
1267
+ const didStop = await this.withLifecycleLock(task.id, async (assertHeld, signal) => {
1268
+ await assertHeld();
1269
+ // A run, viewer, or preview request may have arrived while the
1270
+ // sweep awaited policy resolution or the lifecycle lock.
1271
+ const currentActivity = getPlatformRuntimeConfig().mode === 'team'
1272
+ ? await getCoordinator().getCache(`work-task-activity:${task.id}`)
1273
+ : this.taskActivity.get(task.id);
1274
+ if ((await this.hasRuntimeActivity(task.id)) ||
1275
+ this.hasLocalRuntimeActivity(task.id) ||
1276
+ (currentActivity !== null &&
1277
+ currentActivity !== undefined &&
1278
+ now - currentActivity < idleAfterMs)) {
1279
+ return false;
1280
+ }
1281
+ await this.stopPreviewPrepared(task, signal);
1282
+ await assertHeld();
1283
+ await this.markPreviewStopped(task.id);
1284
+ this.releasePreviewLease(task.id);
1285
+ this.completeRecoveryTask(task.id);
1286
+ return true;
1287
+ });
1288
+ if (!didStop)
1289
+ continue;
1201
1290
  this.taskActivity.delete(task.id);
1202
1291
  stopped += 1;
1203
1292
  logger.info(`Stopped idle Work sandbox ${task.containerName} after ${idleAfterMs}ms of inactivity.`);
@@ -1226,53 +1315,54 @@ export class WorkRuntimeService {
1226
1315
  const candidates = (await workTaskService.listAllTaskRecords()).filter(task => task.previewStatus === 'starting' || task.previewStatus === 'running');
1227
1316
  if (candidates.length === 0)
1228
1317
  return { stopped: 0 };
1229
- let discovered;
1230
1318
  try {
1231
- discovered = await this.driver.listManaged();
1319
+ await this.driver.listManaged();
1232
1320
  }
1233
1321
  catch {
1234
1322
  // The runtime is unreachable; the rows cannot be judged either way.
1235
1323
  return { stopped: 0 };
1236
1324
  }
1237
- const running = new Set(discovered.filter(entry => entry.running).map(entry => entry.taskId));
1238
1325
  let stopped = 0;
1239
1326
  for (const task of candidates) {
1240
- // A preview that is still being brought up, or a task someone holds
1241
- // open, is not stale: leave the owner's own flows to settle it.
1242
- if (this.activeCommands.has(task.id) ||
1243
- (this.terminalHolds.get(task.id) ?? 0) > 0 ||
1244
- (this.screenHolds.get(task.id) ?? 0) > 0 ||
1245
- (this.runtimeLeases.has(task.id) &&
1246
- !this.previewLeaseReleases.has(task.id))) {
1247
- continue;
1248
- }
1249
- if (task.previewStatus === 'starting' &&
1250
- now - task.updatedAt < PREVIEW_START_GRACE_MS) {
1251
- continue;
1252
- }
1253
- if ((await workUsageService.list(task.id)).some(entry => entry.kind !== 'preview')) {
1327
+ if (await this.hasRuntimeActivity(task.id))
1254
1328
  continue;
1255
- }
1256
- if (getPlatformRuntimeConfig().mode === 'team' &&
1257
- (await getCoordinator().listPresence(`work-task-active:${task.id}`))
1258
- .length > 0) {
1259
- continue;
1260
- }
1261
1329
  try {
1262
- // Without a running container the preview cannot exist; with one,
1263
- // the same probe the Work list uses decides.
1264
- if (running.has(task.id) && (await this.isPreviewRunning(task))) {
1330
+ const didReconcile = await this.withLifecycleLock(task.id, async (assertHeld, signal) => {
1331
+ await assertHeld();
1332
+ const row = await getWorkPersistence().findTask(task.id, task.userId);
1333
+ if (!row ||
1334
+ (row.preview_status !== 'starting' &&
1335
+ row.preview_status !== 'running') ||
1336
+ (row.preview_status === 'starting' &&
1337
+ now - row.updated_at < PREVIEW_START_GRACE_MS) ||
1338
+ (await this.hasRuntimeActivity(task.id)) ||
1339
+ this.hasLocalRuntimeActivity(task.id)) {
1340
+ return false;
1341
+ }
1342
+ const sharedPreview = getPlatformRuntimeConfig().mode === 'team' &&
1343
+ (await getCoordinator().listPresence(`work-task-active:${task.id}`)).some(isPreviewOnlyPresence);
1344
+ const state = await this.previewProcessCheckWithLock(task, signal);
1345
+ if (state === 'ready' &&
1346
+ (this.previewLeaseReleases.has(task.id) || sharedPreview))
1347
+ return false;
1348
+ if ((await this.hasRuntimeActivity(task.id)) ||
1349
+ this.hasLocalRuntimeActivity(task.id))
1350
+ return false;
1351
+ if (state !== 'absent')
1352
+ await this.stopPreviewPrepared(task, signal);
1353
+ await assertHeld();
1354
+ await this.markPreviewStopped(task.id);
1355
+ this.releasePreviewLease(task.id);
1356
+ return true;
1357
+ });
1358
+ if (!didReconcile)
1265
1359
  continue;
1266
- }
1360
+ stopped += 1;
1361
+ logger.info(`Marked the stale ${task.previewStatus} preview of Work task ${task.id} as stopped.`);
1267
1362
  }
1268
1363
  catch (error) {
1269
1364
  logger.warn(`Could not check the preview of Work task ${task.id}:`, error);
1270
- continue;
1271
1365
  }
1272
- await this.markPreviewStopped(task.id);
1273
- this.releasePreviewLease(task.id);
1274
- stopped += 1;
1275
- logger.info(`Marked the stale ${task.previewStatus} preview of Work task ${task.id} as stopped.`);
1276
1366
  }
1277
1367
  return { stopped };
1278
1368
  }
@@ -1535,9 +1625,23 @@ export class WorkRuntimeService {
1535
1625
  // transitions (start, stop, recreate, preview) stay lease-guarded.
1536
1626
  if (error instanceof WorkRuntimeError &&
1537
1627
  error.code === 'WORK_RUNTIME_LEASE_CONFLICT') {
1538
- await this.assertTaskIsActive(task);
1539
- if ((await this.driver.runtimeState(task)) === 'running') {
1540
- return operation();
1628
+ const releasePresence = await this.acquireViewerPresence(task.id);
1629
+ try {
1630
+ return await this.withLifecycleLock(task.id, async () => {
1631
+ await this.assertTaskIsActive(task);
1632
+ if ((await this.driver.runtimeState(task)) !== 'running') {
1633
+ throw error;
1634
+ }
1635
+ try {
1636
+ return await operation();
1637
+ }
1638
+ finally {
1639
+ this.noteTaskActivity(task.id);
1640
+ }
1641
+ });
1642
+ }
1643
+ finally {
1644
+ releasePresence();
1541
1645
  }
1542
1646
  }
1543
1647
  throw error;
@@ -1566,6 +1670,16 @@ export class WorkRuntimeService {
1566
1670
  if (!options.ignoreActiveCommands && this.activeCommands.has(task.id)) {
1567
1671
  return false;
1568
1672
  }
1673
+ // Commands and helpers still own their lease during teardown, but screen
1674
+ // viewers do not. Preserve any other holder, including the run executor
1675
+ // retaining the sandbox between tool calls, until explicit run cleanup.
1676
+ const ownLeaseHolders = options.ownsRuntimeLease === false ? 0 : 1;
1677
+ // A registered preview lease is checked for readiness below. Counting it
1678
+ // as another hold would retain dead previews and their capacity forever.
1679
+ const hasOtherRuntimeHolders = () => (this.runtimeLeases.get(task.id)?.holders ?? 0) >
1680
+ ownLeaseHolders + (this.previewLeaseReleases.has(task.id) ? 1 : 0);
1681
+ if (hasOtherRuntimeHolders())
1682
+ return false;
1569
1683
  // An attached terminal session owns the running container exactly like a
1570
1684
  // ready preview does.
1571
1685
  if ((this.terminalHolds.get(task.id) ?? 0) > 0)
@@ -1602,10 +1716,11 @@ export class WorkRuntimeService {
1602
1716
  catch (error) {
1603
1717
  logger.warn(`Could not verify preview state before idling Work container ${task.containerName}; stopping it:`, error);
1604
1718
  }
1605
- // The preview probe above awaits a container exec; a terminal or screen
1606
- // hold can land during that window. Re-check synchronously so a viewer
1607
- // who just attached does not lose the session to a stale decision.
1719
+ // The preview probe above awaits a container exec; a runtime lease or
1720
+ // terminal/screen hold can land during that window. Re-check synchronously
1721
+ // so newly acquired holds do not lose the sandbox to a stale decision.
1608
1722
  if ((!options.ignoreActiveCommands && this.activeCommands.has(task.id)) ||
1723
+ hasOtherRuntimeHolders() ||
1609
1724
  (this.terminalHolds.get(task.id) ?? 0) > 0 ||
1610
1725
  (this.screenHolds.get(task.id) ?? 0) > 0) {
1611
1726
  return false;
@@ -2155,12 +2270,21 @@ export class WorkRuntimeService {
2155
2270
  // idempotent, and lifecycle transitions stay lease-guarded.
2156
2271
  if (error instanceof WorkRuntimeError &&
2157
2272
  error.code === 'WORK_RUNTIME_LEASE_CONFLICT') {
2158
- await this.assertTaskIsActive(task);
2159
- if ((await this.driver.runtimeState(task)) === 'running') {
2160
- await this.startComputerInContainer(task);
2161
- const value = await fn();
2162
- this.noteTaskActivity(task.id);
2163
- return value;
2273
+ const releasePresence = await this.acquireViewerPresence(task.id);
2274
+ try {
2275
+ return await this.withLifecycleLock(task.id, async (_assertHeld, signal) => {
2276
+ await this.assertTaskIsActive(task);
2277
+ if ((await this.driver.runtimeState(task)) !== 'running') {
2278
+ throw error;
2279
+ }
2280
+ await this.startComputerInContainer(task, signal);
2281
+ const value = await fn(signal);
2282
+ this.noteTaskActivity(task.id);
2283
+ return value;
2284
+ });
2285
+ }
2286
+ finally {
2287
+ releasePresence();
2164
2288
  }
2165
2289
  }
2166
2290
  throw error;
@@ -2213,7 +2337,13 @@ export class WorkRuntimeService {
2213
2337
  throw new WorkRuntimeError('Wait for the active command to finish before starting a preview.', 409, 'WORK_PREVIEW_COMMAND_ACTIVE');
2214
2338
  }
2215
2339
  await assertHeld();
2340
+ const startingLease = this.runtimeLeases.get(task.id);
2341
+ if (startingLease) {
2342
+ startingLease.previewGeneration =
2343
+ (startingLease.previewGeneration ?? 0) + 1;
2344
+ }
2216
2345
  await hooks.onStarting?.();
2346
+ let registeredPreview = false;
2217
2347
  try {
2218
2348
  await this.prepareWithLock(task, signal);
2219
2349
  await this.assertTaskIsActive(task);
@@ -2228,28 +2358,53 @@ export class WorkRuntimeService {
2228
2358
  await this.assertTaskIsActive(task);
2229
2359
  await assertHeld();
2230
2360
  await hooks.onRunning?.(preview.url, preview.endpoint);
2361
+ // Invalidate stopped snapshots begun during startup as well as
2362
+ // those begun before it, including a reused preview callback.
2363
+ if (startingLease) {
2364
+ startingLease.previewGeneration =
2365
+ (startingLease.previewGeneration ?? 0) + 1;
2366
+ }
2367
+ if (this.previewLeaseReleases.has(task.id)) {
2368
+ releaseLease();
2369
+ }
2370
+ else {
2371
+ this.previewLeaseReleases.set(task.id, releaseLease);
2372
+ registeredPreview = true;
2373
+ this.previewUsageReleases.set(task.id, await workUsageService.begin(task.id, 'preview', task.userId));
2374
+ }
2375
+ const lease = this.runtimeLeases.get(task.id);
2376
+ if (lease)
2377
+ await this.refreshRuntimePresence(task, lease);
2378
+ leaseRetained = true;
2231
2379
  return preview.url;
2232
2380
  }
2233
2381
  catch (error) {
2234
- await hooks.onFailed?.();
2235
- this.releasePreviewLease(task.id);
2382
+ if (registeredPreview && !this.hasLocalRuntimeActivity(task.id)) {
2383
+ // Retain ownership and the startup lifecycle lock until rollback
2384
+ // has stopped the sandbox. Never stop a newly active run holder.
2385
+ try {
2386
+ await this.stopPreviewPrepared(task, signal);
2387
+ await assertHeld();
2388
+ await this.markPreviewStopped(task.id);
2389
+ }
2390
+ catch (cleanupError) {
2391
+ logger.warn(`Could not clean up failed Work preview ${task.id}:`, cleanupError);
2392
+ }
2393
+ }
2394
+ try {
2395
+ await hooks.onFailed?.();
2396
+ }
2397
+ finally {
2398
+ this.releasePreviewLease(task.id);
2399
+ }
2236
2400
  throw error;
2237
2401
  }
2238
2402
  });
2239
- if (this.previewLeaseReleases.has(task.id)) {
2240
- releaseLease();
2241
- }
2242
- else {
2243
- this.previewLeaseReleases.set(task.id, releaseLease);
2244
- this.previewUsageReleases.set(task.id, await workUsageService.begin(task.id, 'preview', task.userId));
2245
- }
2246
- leaseRetained = true;
2247
2403
  return url;
2248
2404
  }
2249
2405
  finally {
2250
- if (!leaseRetained) {
2406
+ if (!leaseRetained)
2251
2407
  releaseLease();
2252
- }
2253
2408
  }
2254
2409
  }
2255
2410
  async detectPreviewLaunch(task, signal) {
@@ -2424,6 +2579,10 @@ export class WorkRuntimeService {
2424
2579
  if (this.activeCommands.has(task.id))
2425
2580
  return false;
2426
2581
  const state = await this.previewProcessCheckWithLock(task, signal);
2582
+ const sharedPreview = getPlatformRuntimeConfig().mode === 'team' &&
2583
+ (await withCoordinationTimeout(getCoordinator().listPresence(`work-task-active:${task.id}`), SHARED_COORDINATION_OPERATION_TIMEOUT_MS)).some(member => member !== this.activityMemberId);
2584
+ if (sharedPreview)
2585
+ return state === 'ready';
2427
2586
  if (state === 'ready' && this.previewLeaseReleases.has(task.id)) {
2428
2587
  return true;
2429
2588
  }