@morscherlab/mint-sdk 1.0.63 → 1.1.0-beta.10

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 (181) hide show
  1. package/README.md +119 -6
  2. package/dist/EmptyState-DgBRoYAy.js +647 -0
  3. package/dist/EmptyState-DgBRoYAy.js.map +1 -0
  4. package/dist/{ExperimentPopover-BbPkIFsI.js → ExperimentPopover-BFw3QX8L.js} +10 -9
  5. package/dist/{ExperimentPopover-BbPkIFsI.js.map → ExperimentPopover-BFw3QX8L.js.map} +1 -1
  6. package/dist/{ExperimentSelectorModal-DZeSd-yr.js → ExperimentSelectorModal-BBWXC7KW.js} +12 -216
  7. package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +1 -0
  8. package/dist/{SettingsModal-CJgLadsN.js → SettingsModal-Dpg-X1OM.js} +12 -9
  9. package/dist/{SettingsModal-CJgLadsN.js.map → SettingsModal-Dpg-X1OM.js.map} +1 -1
  10. package/dist/__tests__/components/BaseToggle.test.d.ts +1 -0
  11. package/dist/__tests__/components/GeneratedPathInput.test.d.ts +1 -0
  12. package/dist/__tests__/components/GeneratedPlotlyResult.test.d.ts +1 -0
  13. package/dist/__tests__/components/GeneratedPluginView.test.d.ts +1 -0
  14. package/dist/__tests__/components/GeneratedResultRenderer.test.d.ts +1 -0
  15. package/dist/__tests__/components/InstrumentAlertLog.test.d.ts +1 -0
  16. package/dist/__tests__/components/SectionCard.test.d.ts +1 -0
  17. package/dist/__tests__/composables/useGeneratedAnalysis.test.d.ts +1 -0
  18. package/dist/__tests__/generatedUi.test.d.ts +1 -0
  19. package/dist/__tests__/types/GeneratedJsonSchema.test.d.ts +1 -0
  20. package/dist/{analysisArtifactTypes-Cu40dzSG.js → analysisArtifactTypes-ejqR8MX3.js} +1 -1
  21. package/dist/{analysisArtifactTypes-Cu40dzSG.js.map → analysisArtifactTypes-ejqR8MX3.js.map} +1 -1
  22. package/dist/{auth-DZAwEeis.js → auth-B87xM--s.js} +1 -1
  23. package/dist/{auth-DZAwEeis.js.map → auth-B87xM--s.js.map} +1 -1
  24. package/dist/chunk-BoAXSpZd.js +33 -0
  25. package/dist/components/AppSidebar.vue.d.ts +1 -1
  26. package/dist/components/AutoGroupModal.vue.d.ts +2 -2
  27. package/dist/components/BaseSelect.vue.d.ts +1 -1
  28. package/dist/components/BaseToggle.vue.d.ts +2 -0
  29. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  30. package/dist/components/CollapsibleCard.vue.d.ts +1 -1
  31. package/dist/components/ComponentBindingRenderer.vue.d.ts +1 -1
  32. package/dist/components/ConfirmDialog.vue.d.ts +1 -1
  33. package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
  34. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  35. package/dist/components/FileUploader.vue.d.ts +4 -2
  36. package/dist/components/FitPanel.vue.d.ts +2 -2
  37. package/dist/components/FormActions.vue.d.ts +1 -1
  38. package/dist/components/FormBuilder.vue.d.ts +1 -1
  39. package/dist/components/GeneratedPathInput.vue.d.ts +12 -0
  40. package/dist/components/GeneratedPlotlyResult.vue.d.ts +11 -0
  41. package/dist/components/GeneratedPluginView.vue.d.ts +10 -0
  42. package/dist/components/GeneratedResultRenderer.vue.d.ts +6 -0
  43. package/dist/components/JobsStatusTray.vue.d.ts +5 -1
  44. package/dist/components/LcmsSequenceTable.vue.d.ts +1 -1
  45. package/dist/components/PluginWorkspaceView.props.d.ts +1 -1
  46. package/dist/components/SampleLegend.vue.d.ts +1 -1
  47. package/dist/components/SampleSelector.vue.d.ts +2 -2
  48. package/dist/components/SectionCard.vue.d.ts +47 -0
  49. package/dist/components/SettingsModal.vue.d.ts +1 -1
  50. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  51. package/dist/components/index.d.ts +5 -0
  52. package/dist/components/index.js +8 -9
  53. package/dist/components/internal/PlateMapEditorToolbarInternal.vue.d.ts +2 -2
  54. package/dist/{components-DdzHh3a_.js → components-Ci_BTx00.js} +2322 -2292
  55. package/dist/components-Ci_BTx00.js.map +1 -0
  56. package/dist/composables/index.d.ts +3 -2
  57. package/dist/composables/index.js +10 -11
  58. package/dist/composables/useGeneratedAnalysis.d.ts +62 -0
  59. package/dist/composables/useJobsStatusTray.d.ts +48 -4
  60. package/dist/composables/usePlatformContext.d.ts +3 -0
  61. package/dist/composables/usePluginClient.d.ts +17 -8
  62. package/dist/{composables-BUUphBqg.js → composables-C0VEJ73O.js} +154 -38
  63. package/dist/composables-C0VEJ73O.js.map +1 -0
  64. package/dist/experiment-utils-CxkruZ3G.js +392 -0
  65. package/dist/experiment-utils-CxkruZ3G.js.map +1 -0
  66. package/dist/generated/schema.d.ts +7 -0
  67. package/dist/generated-ui/main.d.ts +0 -0
  68. package/dist/index.d.ts +1 -0
  69. package/dist/index.js +15 -18
  70. package/dist/index.js.map +1 -1
  71. package/dist/install.js +7 -8
  72. package/dist/install.js.map +1 -1
  73. package/dist/plotly.min-BR53hja4.js +180634 -0
  74. package/dist/plotly.min-BR53hja4.js.map +1 -0
  75. package/dist/stores/auth.d.ts +2 -2
  76. package/dist/stores/index.js +1 -1
  77. package/dist/styles.css +8657 -8217
  78. package/dist/tailwind.preset.d.ts +5 -0
  79. package/dist/tailwind.preset.js +7 -2
  80. package/dist/tailwind.preset.js.map +1 -1
  81. package/dist/templates/index.js +4 -3
  82. package/dist/templates/packs.d.ts +0 -1
  83. package/dist/{templates-CNbPQNID.js → templates-Cdj93S9C.js} +2 -4
  84. package/dist/templates-Cdj93S9C.js.map +1 -0
  85. package/dist/types/components.d.ts +2 -0
  86. package/dist/types/form-builder.d.ts +1 -1
  87. package/dist/types/generated-ui.d.ts +183 -0
  88. package/dist/types/index.d.ts +2 -1
  89. package/dist/types/index.js +1 -1
  90. package/dist/types/instrument.d.ts +1 -0
  91. package/dist/types/jobs.d.ts +7 -1
  92. package/dist/types/platform.d.ts +2 -0
  93. package/dist/{useControlSchema-BZNdalmL.js → useControlSchema-B_qgU4rW.js} +1882 -172
  94. package/dist/useControlSchema-B_qgU4rW.js.map +1 -0
  95. package/dist/{useDropdownState-Bb-c_PeE.js → useDropdownState-gVaMXtwW.js} +2 -2
  96. package/dist/{useDropdownState-Bb-c_PeE.js.map → useDropdownState-gVaMXtwW.js.map} +1 -1
  97. package/dist/{useExperimentSelector-DRbj6vse.js → useExperimentSelector-BuQZRgMt.js} +4 -255
  98. package/dist/useExperimentSelector-BuQZRgMt.js.map +1 -0
  99. package/dist/{useFormBuilder-DKekvXRs.js → useFormBuilder-D1G5J5Z5.js} +2 -2
  100. package/dist/{useFormBuilder-DKekvXRs.js.map → useFormBuilder-D1G5J5Z5.js.map} +1 -1
  101. package/dist/{useAnalysisArtifacts-C3l0a0vu.js → useProtocolTemplates-DeaOmMaw.js} +307 -475
  102. package/dist/useProtocolTemplates-DeaOmMaw.js.map +1 -0
  103. package/package.json +7 -3
  104. package/src/__tests__/components/BaseSelect.test.ts +52 -0
  105. package/src/__tests__/components/BaseToggle.test.ts +40 -0
  106. package/src/__tests__/components/GeneratedPathInput.test.ts +132 -0
  107. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +117 -0
  108. package/src/__tests__/components/GeneratedPluginView.test.ts +240 -0
  109. package/src/__tests__/components/GeneratedResultRenderer.test.ts +93 -0
  110. package/src/__tests__/components/InstrumentAlertLog.test.ts +59 -0
  111. package/src/__tests__/components/PluginWorkspaceView.test.ts +68 -14
  112. package/src/__tests__/components/SectionCard.test.ts +181 -0
  113. package/src/__tests__/composables/formBuilderRegistry.test.ts +5 -1
  114. package/src/__tests__/composables/useApi.test.ts +8 -0
  115. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +1742 -0
  116. package/src/__tests__/composables/useJobsStatusTray.test.ts +179 -1
  117. package/src/__tests__/composables/usePlatformContext.test.ts +28 -0
  118. package/src/__tests__/composables/usePluginClient.test.ts +402 -21
  119. package/src/__tests__/composables/usePluginConfig.test.ts +323 -7
  120. package/src/__tests__/docs/extractDocsTemplates.test.ts +0 -1
  121. package/src/__tests__/generatedUi.test.ts +119 -0
  122. package/src/__tests__/templates/templates.test.ts +1 -1
  123. package/src/__tests__/types/GeneratedJsonSchema.test.ts +62 -0
  124. package/src/components/BaseSelect.vue +16 -2
  125. package/src/components/BaseToggle.vue +3 -1
  126. package/src/components/FileUploader.vue +49 -11
  127. package/src/components/GeneratedPathInput.vue +218 -0
  128. package/src/components/GeneratedPlotlyResult.vue +87 -0
  129. package/src/components/GeneratedPluginView.vue +279 -0
  130. package/src/components/GeneratedResultRenderer.vue +137 -0
  131. package/src/components/InstrumentAlertLog.vue +2 -2
  132. package/src/components/JobsStatusTray.vue +14 -8
  133. package/src/components/PluginWorkspaceView.props.ts +1 -1
  134. package/src/components/PluginWorkspaceView.vue +1 -5
  135. package/src/components/SectionCard.story.vue +163 -0
  136. package/src/components/SectionCard.vue +98 -0
  137. package/src/components/index.ts +5 -0
  138. package/src/composables/formBuilderRegistry.ts +2 -0
  139. package/src/composables/index.ts +19 -0
  140. package/src/composables/useApi.ts +1 -0
  141. package/src/composables/useGeneratedAnalysis.ts +1270 -0
  142. package/src/composables/useJobsStatusTray.ts +234 -45
  143. package/src/composables/usePluginClient.ts +164 -30
  144. package/src/composables/usePluginConfig.ts +156 -20
  145. package/src/generated/schema.ts +130 -0
  146. package/src/generated-ui/main.ts +21 -0
  147. package/src/index.ts +3 -0
  148. package/src/jobs.ts +5 -0
  149. package/src/plotly-dist.d.ts +3 -0
  150. package/src/styles/components/section-card.css +161 -0
  151. package/src/styles/index.css +1 -0
  152. package/src/styles/variables.css +25 -0
  153. package/src/tailwind.preset.ts +5 -0
  154. package/src/templates/packs.ts +0 -3
  155. package/src/types/components.ts +12 -0
  156. package/src/types/form-builder.ts +1 -0
  157. package/src/types/generated-ui.ts +189 -0
  158. package/src/types/index.ts +4 -0
  159. package/src/types/instrument.ts +1 -0
  160. package/src/types/jobs.ts +10 -1
  161. package/src/types/platform.ts +2 -0
  162. package/dist/BaseButton-Dgqrze41.js +0 -74
  163. package/dist/BaseButton-Dgqrze41.js.map +0 -1
  164. package/dist/BaseModal-B9UA8Y_I.js +0 -165
  165. package/dist/BaseModal-B9UA8Y_I.js.map +0 -1
  166. package/dist/BaseSelect-ekgr9fDo.js +0 -179
  167. package/dist/BaseSelect-ekgr9fDo.js.map +0 -1
  168. package/dist/ExperimentPopover-Ryusjrhv.js +0 -4
  169. package/dist/ExperimentSelectorModal-Cg4-LXBD.js +0 -5
  170. package/dist/ExperimentSelectorModal-DZeSd-yr.js.map +0 -1
  171. package/dist/SettingsModal-Bl_9w2Y3.js +0 -5
  172. package/dist/components-DdzHh3a_.js.map +0 -1
  173. package/dist/composables-BUUphBqg.js.map +0 -1
  174. package/dist/experiment-utils-Bfa7CwPU.js +0 -108
  175. package/dist/experiment-utils-Bfa7CwPU.js.map +0 -1
  176. package/dist/templates-CNbPQNID.js.map +0 -1
  177. package/dist/useAnalysisArtifacts-C3l0a0vu.js.map +0 -1
  178. package/dist/useControlSchema-BZNdalmL.js.map +0 -1
  179. package/dist/useEventListener-CfVkP9Xz.js +0 -57
  180. package/dist/useEventListener-CfVkP9Xz.js.map +0 -1
  181. package/dist/useExperimentSelector-DRbj6vse.js.map +0 -1
@@ -28,7 +28,13 @@ import {
28
28
  pluginEndpointRequestParts,
29
29
  requestBodyFromPayload,
30
30
  } from './pluginEndpointBuilder'
31
- import type { ExperimentSummary, PageSelectorItem, SettingsModalSchema, TopBarSettingsConfig } from '../types'
31
+ import type {
32
+ ExperimentSummary,
33
+ GeneratedJobDefinition,
34
+ PageSelectorItem,
35
+ SettingsModalSchema,
36
+ TopBarSettingsConfig,
37
+ } from '../types'
32
38
 
33
39
  export {
34
40
  buildPluginEndpointUrl,
@@ -39,8 +45,10 @@ export type PluginHttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
39
45
 
40
46
  export interface PluginEndpointContract {
41
47
  name: string
48
+ operationId?: string
42
49
  method: PluginHttpMethod
43
50
  path: string
51
+ requiresAuth?: boolean
44
52
  pathParams?: PluginEndpointParamContract[]
45
53
  queryParams?: PluginEndpointParamContract[]
46
54
  requestType?: string | null
@@ -68,8 +76,16 @@ export interface PluginNavItemContract {
68
76
  requiresFeature?: string
69
77
  }
70
78
 
79
+ export interface PluginContractSettings {
80
+ modelName: string | null
81
+ /** Pydantic JSON Schema used to generate typed settings values. */
82
+ valueSchema: Record<string, unknown> | null
83
+ /** SDK SettingsModal schema used to render the settings form. */
84
+ formSchema: SettingsModalSchema | null
85
+ }
86
+
71
87
  export interface PluginContract {
72
- schemaVersion: number
88
+ schemaVersion: 2
73
89
  plugin: {
74
90
  name?: string
75
91
  packageName?: string
@@ -85,17 +101,17 @@ export interface PluginContract {
85
101
  analysisResultReaders?: string[]
86
102
  capabilities?: Record<string, unknown>
87
103
  }
88
- settings?: {
89
- modelName?: string | null
90
- schema?: SettingsModalSchema | null
91
- }
104
+ settings: PluginContractSettings
92
105
  endpoints: PluginEndpointContract[]
106
+ schemas?: Record<string, Record<string, unknown>>
107
+ jobs?: GeneratedJobDefinition[]
93
108
  hash: string
94
109
  }
95
110
 
96
111
  export interface PluginEndpointDefinition {
97
112
  method: PluginHttpMethod
98
113
  path: string
114
+ requiresAuth?: boolean
99
115
  pathParams?: PluginEndpointParamDefinition[]
100
116
  queryParams?: PluginEndpointParamDefinition[]
101
117
  hasBody?: boolean
@@ -157,7 +173,7 @@ export interface UsePluginSettingsOptions {
157
173
  apiBaseUrl?: string
158
174
  /** Platform API base URL for /plugins/{name}/config. Defaults to the injected platform API URL. */
159
175
  platformApiBaseUrl?: string
160
- /** SettingsModal schema generated from the backend settings_model. */
176
+ /** SettingsModal schema generated from the backend plugin config model. */
161
177
  schema?: SettingsModalSchema | null
162
178
  /** SettingsModal title when using settingsConfig. */
163
179
  title?: string
@@ -383,7 +399,7 @@ function pluginPageIdFromPath(path: string, fallbackIndex: number): string {
383
399
  return normalizedPath.replace(/^\/+/, '').replace(/\/+/g, '-') || `page-${fallbackIndex + 1}`
384
400
  }
385
401
 
386
- /** Convert PluginMetadata.nav_items into AppTopBar pageSelector items for topbar page switches and homepage PluginCards. */
402
+ /** Convert resolved @mint_plugin nav_items into page selectors for the topbar and homepage PluginCards. */
387
403
  export function getPluginPageSelectorItems(contract: PluginContract): PageSelectorItem[] {
388
404
  const pluginIcon = contract.plugin.icon ?? ''
389
405
  const navItems = contract.plugin.navItems ?? []
@@ -413,7 +429,10 @@ export function createPluginClient<TClient extends object = GeneratedPluginClien
413
429
  ? explicitOptions
414
430
  : (payloadOrOptions as PluginClientRequestOptions | undefined) ?? explicitOptions
415
431
  const parts = pluginEndpointRequestParts(contract, endpoint, payload, options.baseUrl)
416
- const api = useApi({ baseUrl: parts.baseUrl })
432
+ const api = useApi({
433
+ baseUrl: parts.baseUrl,
434
+ withAuth: endpoint.requiresAuth !== false,
435
+ })
417
436
  const config = endpointRequestConfig(endpoint, parts.query, callOptions)
418
437
 
419
438
  if (endpoint.method === 'get') {
@@ -456,7 +475,10 @@ export async function uploadPluginEndpoint<TResponse = unknown>(
456
475
  }
457
476
 
458
477
  const parts = pluginEndpointRequestParts(contract, endpoint, payload, options.baseUrl)
459
- const api = useApi({ baseUrl: parts.baseUrl })
478
+ const api = useApi({
479
+ baseUrl: parts.baseUrl,
480
+ withAuth: endpoint.requiresAuth !== false,
481
+ })
460
482
  const body = endpoint.hasBody
461
483
  ? requestBodyFromPayload(payload, parts.requestPayload, endpoint)
462
484
  : payload
@@ -491,7 +513,10 @@ export async function downloadPluginEndpoint(
491
513
  }
492
514
 
493
515
  const parts = pluginEndpointRequestParts(contract, endpoint, payload, options.baseUrl)
494
- const api = useApi({ baseUrl: parts.baseUrl })
516
+ const api = useApi({
517
+ baseUrl: parts.baseUrl,
518
+ withAuth: endpoint.requiresAuth !== false,
519
+ })
495
520
  const config = {
496
521
  ...(endpointRequestConfig(endpoint, parts.query, { signal: options.signal }) ?? {}),
497
522
  responseType: 'blob' as const,
@@ -528,8 +553,14 @@ function normalizePluginSettingsOptions(options: UsePluginSettingsOptions | stri
528
553
  return typeof options === 'string' ? { pluginName: options } : options
529
554
  }
530
555
 
556
+ const PLATFORM_OWNED_SETTINGS_KEY = 'allowed_experiment_types'
557
+
531
558
  function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
532
- return { ...value }
559
+ return JSON.parse(JSON.stringify(value)) as Record<string, unknown>
560
+ }
561
+
562
+ function hasOwnSetting(settings: Record<string, unknown>, key: string): boolean {
563
+ return Object.prototype.hasOwnProperty.call(settings, key)
533
564
  }
534
565
 
535
566
  function responseSettingsPayload(response: unknown): Record<string, unknown> {
@@ -547,6 +578,43 @@ function responseSettingsPayload(response: unknown): Record<string, unknown> {
547
578
  return {}
548
579
  }
549
580
 
581
+ function responseSettingsRevision(response: unknown): string | null {
582
+ if (!response || typeof response !== 'object') return null
583
+ const revision = (response as { revision?: unknown }).revision
584
+ return typeof revision === 'string' && revision ? revision : null
585
+ }
586
+
587
+ function topLevelSettingsPatch(
588
+ current: Record<string, unknown>,
589
+ keys: Iterable<string>,
590
+ ): Record<string, unknown> {
591
+ return Object.fromEntries(
592
+ [...keys].map(key => [
593
+ key,
594
+ hasOwnSetting(current, key) ? current[key] : null,
595
+ ]),
596
+ )
597
+ }
598
+
599
+ function changedTopLevelSettingKeys(
600
+ current: Record<string, unknown>,
601
+ saved: Record<string, unknown>,
602
+ ): Set<string> {
603
+ return new Set(
604
+ [...new Set([...Object.keys(current), ...Object.keys(saved)])].filter(
605
+ key => JSON.stringify(current[key]) !== JSON.stringify(saved[key]),
606
+ ),
607
+ )
608
+ }
609
+
610
+ function platformOwnedSettingsPatch(settings: Record<string, unknown>): Record<string, unknown> {
611
+ return {
612
+ [PLATFORM_OWNED_SETTINGS_KEY]: hasOwnSetting(settings, PLATFORM_OWNED_SETTINGS_KEY)
613
+ ? settings[PLATFORM_OWNED_SETTINGS_KEY]
614
+ : null,
615
+ }
616
+ }
617
+
550
618
  /** Load, edit, and persist plugin settings from platform config or the plugin-local settings router. */
551
619
  export function usePluginSettings<TSettings = Record<string, unknown>>(
552
620
  options: UsePluginSettingsOptions | string = {},
@@ -557,8 +625,9 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
557
625
  const platformApi = useApi({ baseUrl: resolvedOptions.platformApiBaseUrl ?? injectedContext?.platformApiUrl })
558
626
  const pluginApi = useApi({ baseUrl: resolvedOptions.apiBaseUrl ?? injectedContext?.plugin?.api_prefix })
559
627
 
560
- const values = shallowRef<PluginSettingsValues<TSettings>>({} as PluginSettingsValues<TSettings>)
628
+ const values = ref({}) as Ref<PluginSettingsValues<TSettings>>
561
629
  const savedValues = shallowRef<Record<string, unknown>>({})
630
+ const revision = ref<string | null>(null)
562
631
  const request = useRequestSyncState('Plugin settings request failed.')
563
632
  const isLoading = ref(false)
564
633
  const isSaving = ref(false)
@@ -566,8 +635,17 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
566
635
  const resolvedPluginName = computed(() =>
567
636
  resolvedOptions.pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '',
568
637
  )
638
+ const usesPluginSettingsApi = computed(() => {
639
+ const currentPlugin = injectedContext?.plugin ?? plugin.value
640
+ return currentPlugin?.settings_api === 'plugin'
641
+ && resolvedPluginName.value === currentPlugin.name
642
+ })
569
643
  const usesPlatformConfig = computed(() =>
570
- Boolean((isIntegrated.value || injectedContext?.isIntegrated) && resolvedPluginName.value),
644
+ Boolean(
645
+ (isIntegrated.value || injectedContext?.isIntegrated)
646
+ && resolvedPluginName.value
647
+ && !usesPluginSettingsApi.value,
648
+ ),
571
649
  )
572
650
  const settings = computed(() => values.value)
573
651
  const isDirty = computed(() => JSON.stringify(values.value) !== JSON.stringify(savedValues.value))
@@ -582,10 +660,11 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
582
660
  values.value = cloneRecord(nextValues) as PluginSettingsValues<TSettings>
583
661
  }
584
662
 
585
- function applyLoaded(nextValues: Record<string, unknown>): void {
663
+ function applyLoaded(nextValues: Record<string, unknown>, nextRevision: string | null): void {
586
664
  const cloned = cloneRecord(nextValues)
587
665
  values.value = cloned as PluginSettingsValues<TSettings>
588
666
  savedValues.value = cloneRecord(cloned)
667
+ revision.value = nextRevision
589
668
  }
590
669
 
591
670
  async function load(): Promise<void> {
@@ -602,7 +681,10 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
602
681
  () => pluginApi.get<{ settings: Record<string, unknown>; mode?: string }>('/settings'),
603
682
  { success: 'load', errorMessage: 'Failed to load plugin settings' },
604
683
  )
605
- applyLoaded(responseSettingsPayload(response))
684
+ applyLoaded(
685
+ responseSettingsPayload(response),
686
+ responseSettingsRevision(response),
687
+ )
606
688
  } catch {
607
689
  // Error state is handled by request.run().
608
690
  } finally {
@@ -618,19 +700,65 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
618
700
  isSaving.value = true
619
701
  try {
620
702
  const payload = cloneRecord(values.value)
621
- const response = usesPlatformConfig.value
622
- ? await request.run(
623
- () => platformApi.patch<{ plugin_name: string; config: Record<string, unknown> }>(
624
- `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
625
- { config: payload },
626
- ),
627
- { success: 'save', errorMessage: 'Failed to save plugin settings' },
703
+ let response: unknown
704
+ if (usesPlatformConfig.value) {
705
+ const dirtyKeys = changedTopLevelSettingKeys(payload, savedValues.value)
706
+ const platformOwnedChanged = dirtyKeys.has(PLATFORM_OWNED_SETTINGS_KEY)
707
+ const typedKeys = [...dirtyKeys].filter(
708
+ key => key !== PLATFORM_OWNED_SETTINGS_KEY,
628
709
  )
629
- : await request.run(
630
- () => pluginApi.put<{ settings: Record<string, unknown>; mode?: string }>('/settings', payload),
710
+ let nextRevision = revision.value
711
+
712
+ if (typedKeys.length) {
713
+ response = await request.run(
714
+ () => platformApi.patch<{
715
+ plugin_name: string
716
+ config: Record<string, unknown>
717
+ revision?: string
718
+ }>(
719
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
720
+ { config: topLevelSettingsPatch(payload, typedKeys) },
721
+ ),
722
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
723
+ )
724
+ nextRevision = responseSettingsRevision(response) ?? nextRevision
725
+ }
726
+
727
+ if (platformOwnedChanged) {
728
+ response = await request.run(
729
+ () => platformApi.patch<{
730
+ plugin_name: string
731
+ config: Record<string, unknown>
732
+ revision?: string
733
+ }>(
734
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
735
+ { config: platformOwnedSettingsPatch(payload) },
736
+ ),
737
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
738
+ )
739
+ nextRevision = responseSettingsRevision(response) ?? nextRevision
740
+ }
741
+
742
+ if (!response) return true
743
+ applyLoaded(responseSettingsPayload(response), nextRevision)
744
+ return true
745
+ } else {
746
+ const ifMatch = revision.value
747
+ ? { headers: { 'If-Match': `"${revision.value}"` } }
748
+ : undefined
749
+ response = await request.run(
750
+ () => pluginApi.put<{
751
+ settings: Record<string, unknown>
752
+ mode?: string
753
+ revision?: string
754
+ }>('/settings', payload, ifMatch),
631
755
  { success: 'save', errorMessage: 'Failed to save plugin settings' },
632
756
  )
633
- applyLoaded(responseSettingsPayload(response))
757
+ }
758
+ applyLoaded(
759
+ responseSettingsPayload(response),
760
+ responseSettingsRevision(response) ?? revision.value,
761
+ )
634
762
  return true
635
763
  } catch {
636
764
  return false
@@ -668,7 +796,10 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
668
796
  }
669
797
  }
670
798
 
671
- function streamRequestAuth(options: PluginEventStreamOptions<unknown>): {
799
+ function streamRequestAuth(
800
+ options: PluginEventStreamOptions<unknown>,
801
+ policy: { requiresAuth: boolean },
802
+ ): {
672
803
  headers: Headers
673
804
  token?: string
674
805
  } {
@@ -677,11 +808,11 @@ function streamRequestAuth(options: PluginEventStreamOptions<unknown>): {
677
808
  if (!authStore.isInitialized) {
678
809
  authStore.initialize()
679
810
  }
680
- if (authStore.token && !headers.has('Authorization')) {
811
+ if (policy.requiresAuth && authStore.token && !headers.has('Authorization')) {
681
812
  headers.set('Authorization', `Bearer ${authStore.token}`)
682
813
  }
683
814
  headers.set('Accept', 'text/event-stream')
684
- const token = authStore.token
815
+ const token = policy.requiresAuth ? authStore.token : undefined
685
816
  return {
686
817
  headers,
687
818
  token: token && headers.get('Authorization') === `Bearer ${token}` ? token : undefined,
@@ -832,7 +963,10 @@ export function usePluginEventStream<TData = string>(
832
963
  const url = buildPluginEndpointUrl(contract, endpoint, payload, {
833
964
  baseUrl: options.baseUrl,
834
965
  })
835
- const requestAuth = streamRequestAuth(options as PluginEventStreamOptions<unknown>)
966
+ const requestAuth = streamRequestAuth(
967
+ options as PluginEventStreamOptions<unknown>,
968
+ { requiresAuth: endpoint.requiresAuth !== false },
969
+ )
836
970
  const response = await fetch(url, {
837
971
  method: 'GET',
838
972
  headers: requestAuth.headers,
@@ -1,8 +1,57 @@
1
1
  import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue'
2
2
  import { useApi } from './useApi'
3
3
  import { usePlatformContext } from './usePlatformContext'
4
+ import { getInjectedPlatformContext } from './platformContextHelpers'
4
5
  import { useRequestSyncState } from './useRequestSyncState'
5
6
 
7
+ const PLATFORM_OWNED_SETTINGS_KEY = 'allowed_experiment_types'
8
+
9
+ function cloneConfig(config: Record<string, unknown>): Record<string, unknown> {
10
+ return JSON.parse(JSON.stringify(config)) as Record<string, unknown>
11
+ }
12
+
13
+ function hasOwnConfig(config: Record<string, unknown>, key: string): boolean {
14
+ return Object.prototype.hasOwnProperty.call(config, key)
15
+ }
16
+
17
+ function changedTopLevelKeys(
18
+ current: Record<string, unknown>,
19
+ saved: Record<string, unknown>,
20
+ ): Set<string> {
21
+ return new Set(
22
+ [...new Set([...Object.keys(current), ...Object.keys(saved)])].filter(
23
+ key => JSON.stringify(current[key]) !== JSON.stringify(saved[key]),
24
+ ),
25
+ )
26
+ }
27
+
28
+ function topLevelConfigPatch(
29
+ current: Record<string, unknown>,
30
+ keys: Iterable<string>,
31
+ ): Record<string, unknown> {
32
+ return Object.fromEntries(
33
+ [...keys].map(key => [
34
+ key,
35
+ hasOwnConfig(current, key) ? current[key] : null,
36
+ ]),
37
+ )
38
+ }
39
+
40
+ function platformOwnedConfigPatch(config: Record<string, unknown>): Record<string, unknown> {
41
+ return {
42
+ [PLATFORM_OWNED_SETTINGS_KEY]: hasOwnConfig(config, PLATFORM_OWNED_SETTINGS_KEY)
43
+ ? config[PLATFORM_OWNED_SETTINGS_KEY]
44
+ : null,
45
+ }
46
+ }
47
+
48
+ function responseConfigPayload(response: {
49
+ config?: Record<string, unknown>
50
+ settings?: Record<string, unknown>
51
+ }): Record<string, unknown> {
52
+ return response.config ?? response.settings ?? {}
53
+ }
54
+
6
55
  export interface UsePluginConfigReturn {
7
56
  /** Editable plugin configuration values. */
8
57
  config: Ref<Record<string, unknown>>
@@ -28,13 +77,25 @@ export interface UsePluginConfigReturn {
28
77
 
29
78
  /** Loads, saves, and tracks dirty state for a plugin's persistent configuration via the platform API. */
30
79
  export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
31
- const api = useApi()
80
+ const injectedContext = getInjectedPlatformContext()
32
81
  const { plugin } = usePlatformContext()
82
+ const platformApi = useApi()
83
+ const pluginApi = useApi({
84
+ baseUrl: injectedContext?.plugin?.api_prefix ?? plugin.value?.api_prefix,
85
+ })
33
86
 
34
- const resolvedName = computed(() => pluginName ?? plugin.value?.name ?? '')
87
+ const resolvedName = computed(
88
+ () => pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '',
89
+ )
90
+ const usesPluginSettingsApi = computed(() => {
91
+ const currentPlugin = injectedContext?.plugin ?? plugin.value
92
+ return currentPlugin?.settings_api === 'plugin'
93
+ && resolvedName.value === currentPlugin.name
94
+ })
35
95
 
36
96
  const config = ref<Record<string, unknown>>({})
37
97
  const savedConfig = ref<Record<string, unknown>>({})
98
+ const revision = ref<string | null>(null)
38
99
  const request = useRequestSyncState('Plugin config request failed.')
39
100
  const isLoading = ref(false)
40
101
  const isSaving = ref(false)
@@ -52,14 +113,29 @@ export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
52
113
 
53
114
  isLoading.value = true
54
115
  try {
55
- const response = await request.run(
56
- () => api.get<{ plugin_name: string; config: Record<string, unknown> }>(
57
- `/plugins/${encodeURIComponent(name)}/config`,
58
- ),
59
- { success: 'load', errorMessage: 'Failed to load plugin config' },
60
- )
61
- config.value = { ...response.config }
62
- savedConfig.value = { ...response.config }
116
+ const response = usesPluginSettingsApi.value
117
+ ? await request.run(
118
+ () => pluginApi.get<{
119
+ settings: Record<string, unknown>
120
+ mode?: string
121
+ revision?: string
122
+ }>('/settings'),
123
+ { success: 'load', errorMessage: 'Failed to load plugin config' },
124
+ )
125
+ : await request.run(
126
+ () => platformApi.get<{
127
+ plugin_name: string
128
+ config: Record<string, unknown>
129
+ revision?: string
130
+ }>(
131
+ `/plugins/${encodeURIComponent(name)}/config`,
132
+ ),
133
+ { success: 'load', errorMessage: 'Failed to load plugin config' },
134
+ )
135
+ const responseConfig = responseConfigPayload(response)
136
+ config.value = cloneConfig(responseConfig)
137
+ savedConfig.value = cloneConfig(responseConfig)
138
+ revision.value = response.revision ?? null
63
139
  } catch {
64
140
  // Error state is handled by request.run().
65
141
  } finally {
@@ -73,15 +149,75 @@ export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
73
149
 
74
150
  isSaving.value = true
75
151
  try {
76
- const response = await request.run(
77
- () => api.patch<{ plugin_name: string; config: Record<string, unknown> }>(
78
- `/plugins/${encodeURIComponent(name)}/config`,
79
- { config: config.value },
80
- ),
81
- { success: 'save', errorMessage: 'Failed to save plugin config' },
82
- )
83
- config.value = { ...response.config }
84
- savedConfig.value = { ...response.config }
152
+ const payload = cloneConfig(config.value)
153
+ let response: {
154
+ plugin_name?: string
155
+ config?: Record<string, unknown>
156
+ settings?: Record<string, unknown>
157
+ mode?: string
158
+ revision?: string
159
+ } | undefined
160
+ let nextRevision = revision.value
161
+
162
+ if (usesPluginSettingsApi.value) {
163
+ response = await request.run(
164
+ () => pluginApi.put<{
165
+ settings: Record<string, unknown>
166
+ mode?: string
167
+ revision?: string
168
+ }>(
169
+ '/settings',
170
+ payload,
171
+ revision.value
172
+ ? { headers: { 'If-Match': `"${revision.value}"` } }
173
+ : undefined,
174
+ ),
175
+ { success: 'save', errorMessage: 'Failed to save plugin config' },
176
+ )
177
+ nextRevision = response.revision ?? nextRevision
178
+ } else {
179
+ const dirtyKeys = changedTopLevelKeys(payload, savedConfig.value)
180
+ const platformOwnedChanged = dirtyKeys.has(PLATFORM_OWNED_SETTINGS_KEY)
181
+ const typedKeys = [...dirtyKeys].filter(
182
+ key => key !== PLATFORM_OWNED_SETTINGS_KEY,
183
+ )
184
+
185
+ if (typedKeys.length) {
186
+ response = await request.run(
187
+ () => platformApi.patch<{
188
+ plugin_name: string
189
+ config: Record<string, unknown>
190
+ revision?: string
191
+ }>(
192
+ `/plugins/${encodeURIComponent(name)}/config`,
193
+ { config: topLevelConfigPatch(payload, typedKeys) },
194
+ ),
195
+ { success: 'save', errorMessage: 'Failed to save plugin config' },
196
+ )
197
+ nextRevision = response.revision ?? nextRevision
198
+ }
199
+
200
+ if (platformOwnedChanged) {
201
+ response = await request.run(
202
+ () => platformApi.patch<{
203
+ plugin_name: string
204
+ config: Record<string, unknown>
205
+ revision?: string
206
+ }>(
207
+ `/plugins/${encodeURIComponent(name)}/config`,
208
+ { config: platformOwnedConfigPatch(payload) },
209
+ ),
210
+ { success: 'save', errorMessage: 'Failed to save plugin config' },
211
+ )
212
+ nextRevision = response.revision ?? nextRevision
213
+ }
214
+ }
215
+
216
+ if (!response) return true
217
+ const responseConfig = responseConfigPayload(response)
218
+ config.value = cloneConfig(responseConfig)
219
+ savedConfig.value = cloneConfig(responseConfig)
220
+ revision.value = response.revision ?? nextRevision
85
221
  return true
86
222
  } catch {
87
223
  return false
@@ -91,7 +227,7 @@ export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
91
227
  }
92
228
 
93
229
  function reset(): void {
94
- config.value = { ...savedConfig.value }
230
+ config.value = cloneConfig(savedConfig.value)
95
231
  request.clearError()
96
232
  }
97
233
 
@@ -0,0 +1,130 @@
1
+ import type {
2
+ FormFieldSchema,
3
+ FormFieldType,
4
+ FormSchema,
5
+ } from '../types/form-builder'
6
+ import type {
7
+ GeneratedJsonSchema,
8
+ GeneratedJobDefinition,
9
+ } from '../types/generated-ui'
10
+
11
+ type GeneratedReference = 'path'
12
+
13
+ interface ResolvedGeneratedField {
14
+ schema: GeneratedJsonSchema
15
+ reference?: GeneratedReference
16
+ hasDefault: boolean
17
+ defaultValue?: unknown
18
+ }
19
+
20
+ export function generatedJobFormSchema(definition: GeneratedJobDefinition): FormSchema & { sections: NonNullable<FormSchema['sections']> } {
21
+ const root = definition.input_schema
22
+ const required = new Set(root.required ?? [])
23
+ const fields = Object.entries(root.properties ?? {}).map(([name, schema]) =>
24
+ toFormField(name, resolveGeneratedField(root, schema), required.has(name)),
25
+ )
26
+ return {
27
+ sections: [{
28
+ id: definition.id,
29
+ title: definition.title,
30
+ description: definition.description || undefined,
31
+ columns: 1,
32
+ fields,
33
+ }],
34
+ submitLabel: 'Run analysis',
35
+ }
36
+ }
37
+
38
+ export function generatedJobDefaults(definition: GeneratedJobDefinition): Record<string, unknown> {
39
+ return Object.fromEntries(
40
+ Object.entries(definition.input_schema.properties ?? {})
41
+ .filter(([, schema]) => hasOwnDefault(schema))
42
+ .map(([name, schema]) => [name, structuredClone(schema.default)]),
43
+ )
44
+ }
45
+
46
+ export function normalizeGeneratedJobInput(
47
+ definition: GeneratedJobDefinition,
48
+ values: Record<string, unknown>,
49
+ ): Record<string, unknown> {
50
+ const normalized: Record<string, unknown> = {}
51
+ for (const [name, value] of Object.entries(values)) {
52
+ const field = definition.input_schema.properties?.[name]
53
+ if (!field) continue
54
+ normalized[name] = value
55
+ }
56
+ return normalized
57
+ }
58
+
59
+ function toFormField(
60
+ name: string,
61
+ resolved: ResolvedGeneratedField,
62
+ required: boolean,
63
+ ): FormFieldSchema {
64
+ const { schema, reference } = resolved
65
+ const validation: NonNullable<FormFieldSchema['validation']> = {}
66
+ if (required) validation.required = true
67
+ if (schema.minimum !== undefined) validation.min = schema.minimum
68
+ if (schema.maximum !== undefined) validation.max = schema.maximum
69
+ if (schema.minLength !== undefined) validation.minLength = schema.minLength
70
+ if (schema.maxLength !== undefined) validation.maxLength = schema.maxLength
71
+
72
+ const props: Record<string, unknown> = {}
73
+ if (schema.enum) props.options = [...schema.enum]
74
+ if (reference === 'path' && schema.type === 'array') props.multiple = true
75
+ return {
76
+ name,
77
+ label: schema.title || humanize(name),
78
+ type: fieldType(schema, reference),
79
+ defaultValue: resolved.hasDefault ? structuredClone(resolved.defaultValue) : undefined,
80
+ hint: schema.description,
81
+ validation,
82
+ props,
83
+ }
84
+ }
85
+
86
+ function resolveGeneratedField(
87
+ root: GeneratedJsonSchema,
88
+ field: GeneratedJsonSchema,
89
+ ): ResolvedGeneratedField {
90
+ const hasDefault = hasOwnDefault(field)
91
+ const candidate = field.anyOf?.find(item => item.type !== 'null')
92
+ ?? field.allOf?.[0]
93
+ ?? field
94
+ if (candidate.$ref) {
95
+ const referenceParts = candidate.$ref.split('/')
96
+ const key = referenceParts[referenceParts.length - 1]
97
+ const schema = key ? root.$defs?.[key] : undefined
98
+ if (!schema) throw new Error(`Generated field reference is missing: ${candidate.$ref}`)
99
+ return {
100
+ schema: { ...schema, title: field.title ?? schema.title },
101
+ reference: field['x-mint-component'] ?? schema['x-mint-component'],
102
+ hasDefault,
103
+ defaultValue: field.default,
104
+ }
105
+ }
106
+ return {
107
+ schema: candidate,
108
+ reference: field['x-mint-component'] ?? candidate['x-mint-component'],
109
+ hasDefault,
110
+ defaultValue: field.default,
111
+ }
112
+ }
113
+
114
+ function fieldType(schema: GeneratedJsonSchema, reference?: GeneratedReference): FormFieldType {
115
+ if (reference === 'path') return 'path'
116
+ if (schema.enum) return 'select'
117
+ const type = Array.isArray(schema.type) ? schema.type.find(value => value !== 'null') : schema.type
118
+ if (type === 'boolean') return 'toggle'
119
+ if (type === 'number' || type === 'integer') return 'number'
120
+ if (type === 'array') return 'tags'
121
+ return 'text'
122
+ }
123
+
124
+ function hasOwnDefault(schema: GeneratedJsonSchema): boolean {
125
+ return Object.prototype.hasOwnProperty.call(schema, 'default')
126
+ }
127
+
128
+ function humanize(value: string): string {
129
+ return value.replace(/[_-]+/g, ' ').replace(/^\w/, character => character.toUpperCase())
130
+ }