@cat-factory/app 0.150.0 → 0.150.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,6 +77,44 @@ const schemeItems = computed(() => [
77
77
  { label: 'http', value: 'http' },
78
78
  ])
79
79
 
80
+ // A stored non-secret value is `unknown`; coerce it to the string the form field holds,
81
+ // defaulting a missing/wrong-typed value to '' (the same `typeof … === 'string' ? … : ''`
82
+ // each field used inline before).
83
+ function readString(v: unknown): string {
84
+ return typeof v === 'string' ? v : ''
85
+ }
86
+
87
+ // Prefill the manifest-source fields from the stored discriminated config.
88
+ function applyManifestSource(k: Record<string, unknown>): void {
89
+ const src = k.manifestSource as Record<string, unknown> | undefined
90
+ if (src?.type === 'separate') {
91
+ form.manifestSourceType = 'separate'
92
+ form.manifestRepo = readString(src.repo)
93
+ form.manifestRef = readString(src.ref)
94
+ form.manifestPath = readString(src.path)
95
+ } else if (src?.type === 'colocated') {
96
+ form.manifestSourceType = 'colocated'
97
+ form.manifestPath = readString(src.path)
98
+ }
99
+ }
100
+
101
+ // Prefill the URL-derivation fields (+ scheme) from the stored discriminated config.
102
+ function applyUrl(k: Record<string, unknown>): void {
103
+ const url = k.url as Record<string, unknown> | undefined
104
+ if (url?.source === 'ingressTemplate') {
105
+ form.urlSource = 'ingressTemplate'
106
+ form.hostTemplate = readString(url.hostTemplate)
107
+ } else if (url?.source === 'ingressStatus') {
108
+ form.urlSource = 'ingressStatus'
109
+ form.ingressName = readString(url.ingressName)
110
+ } else if (url?.source === 'serviceStatus') {
111
+ form.urlSource = 'serviceStatus'
112
+ form.serviceName = readString(url.serviceName)
113
+ form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
114
+ }
115
+ form.urlScheme = url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
116
+ }
117
+
80
118
  // A registered k8s env connection exposes its non-secret config, so prefill every
81
119
  // non-secret field from it (never the token — secrets are write-only and re-entered on
82
120
  // update). This lets an edit change one field without re-typing the whole form.
@@ -89,36 +127,14 @@ watch(
89
127
  ? (c.config as { kubernetes: Record<string, unknown> }).kubernetes
90
128
  : undefined
91
129
  if (!k) return
92
- form.label = typeof k.label === 'string' ? k.label : ''
93
- form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
94
- form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
130
+ form.label = readString(k.label)
131
+ form.apiServerUrl = readString(k.apiServerUrl)
132
+ form.caCertPem = readString(k.caCertPem)
95
133
  form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
96
- form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
97
- form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
98
- const src = k.manifestSource as Record<string, unknown> | undefined
99
- if (src?.type === 'separate') {
100
- form.manifestSourceType = 'separate'
101
- form.manifestRepo = typeof src.repo === 'string' ? src.repo : ''
102
- form.manifestRef = typeof src.ref === 'string' ? src.ref : ''
103
- form.manifestPath = typeof src.path === 'string' ? src.path : ''
104
- } else if (src?.type === 'colocated') {
105
- form.manifestSourceType = 'colocated'
106
- form.manifestPath = typeof src.path === 'string' ? src.path : ''
107
- }
108
- const url = k.url as Record<string, unknown> | undefined
109
- if (url?.source === 'ingressTemplate') {
110
- form.urlSource = 'ingressTemplate'
111
- form.hostTemplate = typeof url.hostTemplate === 'string' ? url.hostTemplate : ''
112
- } else if (url?.source === 'ingressStatus') {
113
- form.urlSource = 'ingressStatus'
114
- form.ingressName = typeof url.ingressName === 'string' ? url.ingressName : ''
115
- } else if (url?.source === 'serviceStatus') {
116
- form.urlSource = 'serviceStatus'
117
- form.serviceName = typeof url.serviceName === 'string' ? url.serviceName : ''
118
- form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
119
- }
120
- form.urlScheme =
121
- url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
134
+ form.namespaceTemplate = readString(k.namespaceTemplate)
135
+ form.imageTemplate = readString(k.imageTemplate)
136
+ applyManifestSource(k)
137
+ applyUrl(k)
122
138
  },
123
139
  { immediate: true },
124
140
  )
@@ -186,200 +186,231 @@ export function parseConflict(
186
186
  }
187
187
  }
188
188
 
189
+ /** The non-null parsed shape of a backend conflict, as returned by {@link parseConflict}. */
190
+ type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
191
+
189
192
  export function usePipelineErrorToast() {
190
193
  const toast = useToast()
191
194
  const ui = useUiStore()
192
195
  const { t, te } = useI18n()
193
196
 
194
- /**
195
- * Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
196
- * non-conflict failures and any conflict reason without a dedicated title.
197
- */
198
- function present(error: unknown, fallbackTitleKey = 'common.actionFailed'): void {
199
- const conflict = parseConflict(error)
197
+ // The headline case: a pipeline step's model has no usable provider. Name the
198
+ // offending model(s), explain no provider is available, and offer the one-click jump
199
+ // to the AI setup the same remedy the startup "No AI model configured" banner gives.
200
+ function presentProvidersUnconfigured(conflict: ParsedConflict): void {
201
+ const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
202
+ const list = models.join(', ')
203
+ toast.add({
204
+ title: t('errors.conflict.providersUnconfigured.title'),
205
+ description: list
206
+ ? t('errors.conflict.providersUnconfigured.body', { models: list })
207
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
208
+ color: 'error',
209
+ icon: 'i-lucide-cpu',
210
+ // Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
211
+ // auto-dismiss takes the one-click fix with it before the user can reach it.
212
+ duration: 0,
213
+ actions: [
214
+ {
215
+ label: t('errors.conflict.providersUnconfigured.action'),
216
+ icon: 'i-lucide-settings',
217
+ onClick: () => ui.openAiProviderSetup(),
218
+ },
219
+ ],
220
+ })
221
+ }
200
222
 
201
- // The headline case: a pipeline step's model has no usable provider. Name the
202
- // offending model(s), explain no provider is available, and offer the one-click jump
203
- // to the AI setup — the same remedy the startup "No AI model configured" banner gives.
204
- if (conflict?.reason === 'providers_unconfigured') {
205
- const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
206
- const list = models.join(', ')
207
- toast.add({
208
- title: t('errors.conflict.providersUnconfigured.title'),
209
- description: list
210
- ? t('errors.conflict.providersUnconfigured.body', { models: list })
211
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
212
- color: 'error',
213
- icon: 'i-lucide-cpu',
214
- // Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
215
- // auto-dismiss takes the one-click fix with it before the user can reach it.
216
- duration: 0,
217
- actions: [
218
- {
219
- label: t('errors.conflict.providersUnconfigured.action'),
220
- icon: 'i-lucide-settings',
221
- onClick: () => ui.openAiProviderSetup(),
222
- },
223
- ],
224
- })
225
- return
226
- }
223
+ // A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
224
+ // but the account has none configured. Explain it and offer the jump to the content-storage
225
+ // settings — the same shape as the providers-unconfigured case above. Prefer the localized
226
+ // body (it carries no runtime interpolation) so non-English users see translated copy; the
227
+ // raw backend prose is only the last-resort fallback when the locale lacks the key.
228
+ function presentBinaryStorageUnconfigured(conflict: ParsedConflict): void {
229
+ toast.add({
230
+ title: t('errors.conflict.binaryStorageUnconfigured.title'),
231
+ description: te('errors.conflict.binaryStorageUnconfigured.body')
232
+ ? t('errors.conflict.binaryStorageUnconfigured.body')
233
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
234
+ color: 'error',
235
+ icon: 'i-lucide-image',
236
+ // Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
237
+ // remedy reachable instead of letting it auto-dismiss.
238
+ duration: 0,
239
+ actions: [
240
+ {
241
+ label: t('errors.conflict.binaryStorageUnconfigured.action'),
242
+ icon: 'i-lucide-settings',
243
+ onClick: () => ui.openContentStorageSettings(),
244
+ },
245
+ ],
246
+ })
247
+ }
227
248
 
228
- // A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
229
- // but the account has none configured. Explain it and offer the jump to the content-storage
230
- // settings — the same shape as the providers-unconfigured case above. Prefer the localized
231
- // body (it carries no runtime interpolation) so non-English users see translated copy; the
232
- // raw backend prose is only the last-resort fallback when the locale lacks the key.
233
- if (conflict?.reason === 'binary_storage_unconfigured') {
234
- toast.add({
235
- title: t('errors.conflict.binaryStorageUnconfigured.title'),
236
- description: te('errors.conflict.binaryStorageUnconfigured.body')
237
- ? t('errors.conflict.binaryStorageUnconfigured.body')
238
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
239
- color: 'error',
240
- icon: 'i-lucide-image',
241
- // Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
242
- // remedy reachable instead of letting it auto-dismiss.
243
- duration: 0,
244
- actions: [
245
- {
246
- label: t('errors.conflict.binaryStorageUnconfigured.action'),
247
- icon: 'i-lucide-settings',
248
- onClick: () => ui.openContentStorageSettings(),
249
+ // A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
250
+ // "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
251
+ // environment config — the compose wizard for docker-compose, the service inspector otherwise
252
+ // falling back to the workspace infrastructure window if the frame id wasn't carried.
253
+ function presentDeployerServiceConfig(conflict: ParsedConflict): void {
254
+ const frameId =
255
+ typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
256
+ const provisionType =
257
+ typeof conflict.details.provisionType === 'string'
258
+ ? conflict.details.provisionType
259
+ : undefined
260
+ const missing = Array.isArray(conflict.details.missing)
261
+ ? conflict.details.missing.join(', ')
262
+ : ''
263
+ toast.add({
264
+ title: t('errors.conflict.deployerServiceConfig.title'),
265
+ description: missing
266
+ ? t('errors.conflict.deployerServiceConfig.body', { missing })
267
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
268
+ color: 'error',
269
+ icon: 'i-lucide-server',
270
+ // Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
271
+ duration: 0,
272
+ actions: [
273
+ {
274
+ label: t('errors.conflict.deployerServiceConfig.action'),
275
+ icon: 'i-lucide-settings',
276
+ onClick: () => {
277
+ if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
278
+ else if (frameId) ui.select(frameId)
279
+ else ui.openProviderConnection('environment')
249
280
  },
250
- ],
251
- })
252
- return
253
- }
281
+ },
282
+ ],
283
+ })
284
+ }
254
285
 
255
- // A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
256
- // "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
257
- // environment config the compose wizard for docker-compose, the service inspector otherwise
258
- // falling back to the workspace infrastructure window if the frame id wasn't carried.
259
- if (conflict?.reason === 'deployer_service_provisioning_incomplete') {
260
- const frameId =
261
- typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
262
- const provisionType =
263
- typeof conflict.details.provisionType === 'string'
264
- ? conflict.details.provisionType
265
- : undefined
266
- const missing = Array.isArray(conflict.details.missing)
267
- ? conflict.details.missing.join(', ')
268
- : ''
269
- toast.add({
270
- title: t('errors.conflict.deployerServiceConfig.title'),
271
- description: missing
272
- ? t('errors.conflict.deployerServiceConfig.body', { missing })
273
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
274
- color: 'error',
275
- icon: 'i-lucide-server',
276
- // Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
277
- duration: 0,
278
- actions: [
279
- {
280
- label: t('errors.conflict.deployerServiceConfig.action'),
281
- icon: 'i-lucide-settings',
282
- onClick: () => {
283
- if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
284
- else if (frameId) ui.select(frameId)
285
- else ui.openProviderConnection('environment')
286
- },
287
- },
288
- ],
289
- })
290
- return
291
- }
286
+ // A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
287
+ // resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
288
+ // window's Test-environments tab. (Also raised by the Tester start gate same fix applies.)
289
+ function presentProvisionTypeUnhandled(conflict: ParsedConflict): void {
290
+ const type =
291
+ typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
292
+ toast.add({
293
+ title: t('errors.conflict.provisionTypeUnhandled.title'),
294
+ description: type
295
+ ? t('errors.conflict.provisionTypeUnhandled.body', { type })
296
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
297
+ color: 'error',
298
+ icon: 'i-lucide-server-cog',
299
+ duration: 0,
300
+ actions: [
301
+ {
302
+ label: t('errors.conflict.provisionTypeUnhandled.action'),
303
+ icon: 'i-lucide-settings',
304
+ onClick: () => ui.openProviderConnection('environment'),
305
+ },
306
+ ],
307
+ })
308
+ }
292
309
 
293
- // A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
294
- // resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
295
- // window's Test-environments tab. (Also raised by the Tester start gate same fix applies.)
296
- if (conflict?.reason === 'provision_type_unhandled') {
297
- const type =
298
- typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
299
- toast.add({
300
- title: t('errors.conflict.provisionTypeUnhandled.title'),
301
- description: type
302
- ? t('errors.conflict.provisionTypeUnhandled.body', { type })
303
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
304
- color: 'error',
305
- icon: 'i-lucide-server-cog',
306
- duration: 0,
307
- actions: [
308
- {
309
- label: t('errors.conflict.provisionTypeUnhandled.action'),
310
- icon: 'i-lucide-settings',
311
- onClick: () => ui.openProviderConnection('environment'),
312
- },
313
- ],
314
- })
315
- return
310
+ // A pipeline includes a Deployer, the config is structurally complete, but the live connection
311
+ // probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
312
+ // token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
313
+ function presentDeployerConnectionFailed(conflict: ParsedConflict): void {
314
+ const detail = typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
315
+ toast.add({
316
+ title: t('errors.conflict.deployerConnectionFailed.title'),
317
+ description: detail
318
+ ? t('errors.conflict.deployerConnectionFailed.body', { detail })
319
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
320
+ color: 'error',
321
+ icon: 'i-lucide-plug',
322
+ duration: 0,
323
+ actions: [
324
+ {
325
+ label: t('errors.conflict.deployerConnectionFailed.action'),
326
+ icon: 'i-lucide-settings',
327
+ onClick: () => ui.openProviderConnection('environment'),
328
+ },
329
+ ],
330
+ })
331
+ }
332
+
333
+ /**
334
+ * Dispatch the bespoke conflict reasons (a runtime-interpolated body + a "configure X" action,
335
+ * each with its own key namespace — the ones excluded from `CONFLICT_INFO`). Returns `true` when
336
+ * the reason was one of them (and the toast was raised), `false` to fall through to the generic
337
+ * map. The reason values are mutually exclusive, so dispatch order is irrelevant.
338
+ */
339
+ function presentBespokeConflict(conflict: ParsedConflict): boolean {
340
+ switch (conflict.reason) {
341
+ case 'providers_unconfigured':
342
+ presentProvidersUnconfigured(conflict)
343
+ return true
344
+ case 'binary_storage_unconfigured':
345
+ presentBinaryStorageUnconfigured(conflict)
346
+ return true
347
+ case 'deployer_service_provisioning_incomplete':
348
+ presentDeployerServiceConfig(conflict)
349
+ return true
350
+ case 'provision_type_unhandled':
351
+ presentProvisionTypeUnhandled(conflict)
352
+ return true
353
+ case 'deployer_connection_test_failed':
354
+ presentDeployerConnectionFailed(conflict)
355
+ return true
356
+ default:
357
+ return false
316
358
  }
359
+ }
317
360
 
318
- // A pipeline includes a Deployer, the config is structurally complete, but the live connection
319
- // probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
320
- // token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
321
- if (conflict?.reason === 'deployer_connection_test_failed') {
322
- const detail =
323
- typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
361
+ /**
362
+ * Per-reason copy from the exhaustive map: a translated title + description, and a jump
363
+ * action for the reasons a UI panel can fix. `te` (translation-exists) guards every lookup,
364
+ * so a key missing from the active locale falls back rather than leaking a raw key: the
365
+ * title falls to the caller's key, the description to the raw backend `message`. An unknown
366
+ * reason (not in the map) gets the same generic title + raw-message fallback.
367
+ */
368
+ function presentMappedConflict(conflict: ParsedConflict, fallbackTitleKey: string): void {
369
+ const info = conflict.reason
370
+ ? CONFLICT_INFO[conflict.reason as Exclude<ConflictReason, BespokeConflictReason>]
371
+ : undefined
372
+ if (info) {
324
373
  toast.add({
325
- title: t('errors.conflict.deployerConnectionFailed.title'),
326
- description: detail
327
- ? t('errors.conflict.deployerConnectionFailed.body', { detail })
374
+ title: te(info.titleKey) ? t(info.titleKey) : t(fallbackTitleKey),
375
+ description: te(info.descriptionKey)
376
+ ? t(info.descriptionKey)
328
377
  : (conflict.message ?? t('errors.conflict.fallbackMessage')),
329
- color: 'error',
330
- icon: 'i-lucide-plug',
331
- duration: 0,
332
- actions: [
333
- {
334
- label: t('errors.conflict.deployerConnectionFailed.action'),
335
- icon: 'i-lucide-settings',
336
- onClick: () => ui.openProviderConnection('environment'),
337
- },
338
- ],
378
+ color: 'warning',
379
+ icon: 'i-lucide-triangle-alert',
380
+ // A reason with a jump action becomes an actionable, sticky toast (like the bespoke
381
+ // conflicts above) so the one-click remedy doesn't auto-dismiss before it's reached.
382
+ ...(info.action
383
+ ? {
384
+ duration: 0,
385
+ actions: [
386
+ {
387
+ label: t(info.action.labelKey),
388
+ icon: info.action.icon,
389
+ onClick: () => info.action?.run(ui),
390
+ },
391
+ ],
392
+ }
393
+ : {}),
339
394
  })
340
395
  return
341
396
  }
397
+ toast.add({
398
+ title: t(fallbackTitleKey),
399
+ description: conflict.message ?? t('errors.conflict.fallbackMessage'),
400
+ color: 'warning',
401
+ icon: 'i-lucide-triangle-alert',
402
+ })
403
+ }
342
404
 
405
+ /**
406
+ * Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
407
+ * non-conflict failures and any conflict reason without a dedicated title.
408
+ */
409
+ function present(error: unknown, fallbackTitleKey = 'common.actionFailed'): void {
410
+ const conflict = parseConflict(error)
343
411
  if (conflict) {
344
- // Per-reason copy from the exhaustive map: a translated title + description, and a jump
345
- // action for the reasons a UI panel can fix. `te` (translation-exists) guards every lookup,
346
- // so a key missing from the active locale falls back rather than leaking a raw key: the
347
- // title falls to the caller's key, the description to the raw backend `message`. An unknown
348
- // reason (not in the map) gets the same generic title + raw-message fallback.
349
- const info = conflict.reason
350
- ? CONFLICT_INFO[conflict.reason as Exclude<ConflictReason, BespokeConflictReason>]
351
- : undefined
352
- if (info) {
353
- toast.add({
354
- title: te(info.titleKey) ? t(info.titleKey) : t(fallbackTitleKey),
355
- description: te(info.descriptionKey)
356
- ? t(info.descriptionKey)
357
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
358
- color: 'warning',
359
- icon: 'i-lucide-triangle-alert',
360
- // A reason with a jump action becomes an actionable, sticky toast (like the bespoke
361
- // conflicts above) so the one-click remedy doesn't auto-dismiss before it's reached.
362
- ...(info.action
363
- ? {
364
- duration: 0,
365
- actions: [
366
- {
367
- label: t(info.action.labelKey),
368
- icon: info.action.icon,
369
- onClick: () => info.action?.run(ui),
370
- },
371
- ],
372
- }
373
- : {}),
374
- })
375
- return
376
- }
377
- toast.add({
378
- title: t(fallbackTitleKey),
379
- description: conflict.message ?? t('errors.conflict.fallbackMessage'),
380
- color: 'warning',
381
- icon: 'i-lucide-triangle-alert',
382
- })
412
+ if (presentBespokeConflict(conflict)) return
413
+ presentMappedConflict(conflict, fallbackTitleKey)
383
414
  return
384
415
  }
385
416
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.150.0",
3
+ "version": "0.150.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",