@cat-factory/app 0.70.0 → 0.71.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.
- package/app/components/auth/LoginScreen.vue +3 -2
- package/app/components/board/AddTaskModal.vue +7 -1
- package/app/components/board/AgentFailureCard.vue +3 -0
- package/app/components/board/RecurringPipelineModal.vue +6 -1
- package/app/components/board/nodes/BlockNode.vue +10 -2
- package/app/components/board/nodes/TaskCard.vue +12 -1
- package/app/components/bootstrap/BootstrapModal.vue +3 -0
- package/app/components/common/ConfirmDialog.vue +59 -0
- package/app/components/common/EmptyState.vue +35 -0
- package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
- package/app/components/documents/ContextDocumentPicker.vue +8 -4
- package/app/components/documents/DocumentSourceConnectModal.vue +4 -3
- package/app/components/focus/BlockFocusView.vue +12 -7
- package/app/components/fragments/FragmentLibraryManager.vue +10 -0
- package/app/components/github/GitHubPanel.vue +9 -0
- package/app/components/humanTest/HumanTestWindow.vue +15 -1
- package/app/components/layout/AccountDeploymentSettings.vue +4 -0
- package/app/components/layout/AccountTeamSettings.vue +8 -2
- package/app/components/layout/AiProvidersBanner.vue +3 -1
- package/app/components/layout/BoardSwitcher.vue +11 -0
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/InfraSetupBanner.vue +192 -0
- package/app/components/layout/NotificationsInbox.vue +11 -0
- package/app/components/layout/ProviderConfigBanner.vue +3 -1
- package/app/components/panels/InspectorPanel.vue +18 -20
- package/app/components/panels/StepContainerStatus.vue +37 -0
- package/app/components/panels/inspector/FrontendConfig.vue +19 -4
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +4 -0
- package/app/components/panels/inspector/TaskDependencies.vue +18 -4
- package/app/components/panels/inspector/TaskExecution.vue +24 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +8 -1
- package/app/components/pipeline/PipelineBuilder.vue +13 -1
- package/app/components/providers/ApiKeysSection.vue +4 -0
- package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
- package/app/components/providers/VendorCredentialsModal.vue +12 -3
- package/app/components/settings/CustomManifestTypeEditor.vue +3 -0
- package/app/components/settings/InfraHandlersConfigurator.vue +72 -12
- package/app/components/settings/KubernetesEngineForm.vue +41 -5
- package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
- package/app/components/settings/MergeThresholdsPanel.vue +9 -0
- package/app/components/settings/ModelConfigurationPanel.vue +9 -0
- package/app/components/settings/ObservabilityConnectionPanel.vue +7 -0
- package/app/components/settings/ProviderConnectionTab.vue +2 -0
- package/app/components/settings/UserSecretsSection.vue +11 -0
- package/app/components/slack/SlackPanel.vue +9 -0
- package/app/components/tasks/ContextIssuePicker.vue +8 -4
- package/app/components/tasks/TaskSourceConnectModal.vue +4 -3
- package/app/composables/useBlockDeletion.ts +63 -0
- package/app/composables/useConfirm.ts +63 -0
- package/app/composables/useConfirmAction.ts +96 -0
- package/app/composables/useKeyboardShortcuts.ts +76 -0
- package/app/composables/usePipelineErrorToast.ts +2 -0
- package/app/composables/useWorkspaceStream.ts +26 -6
- package/app/pages/index.vue +28 -4
- package/app/stores/agentRuns.spec.ts +74 -0
- package/app/stores/agentRuns.ts +33 -5
- package/app/stores/ui.ts +33 -1
- package/app/stores/workspace.ts +10 -2
- package/app/types/domain.ts +3 -0
- package/app/utils/pipeline.ts +22 -0
- package/i18n/locales/en.json +174 -12
- package/i18n/locales/es.json +176 -17
- package/i18n/locales/fr.json +176 -17
- package/i18n/locales/he.json +176 -17
- package/i18n/locales/ja.json +176 -17
- package/i18n/locales/pl.json +176 -17
- package/i18n/locales/tr.json +176 -17
- package/i18n/locales/uk.json +176 -17
- package/package.json +3 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confirm-gate + success-toast for the recurring destructive actions that aren't board
|
|
3
|
+
* blocks (disconnect a connection, remove/revoke a credential, clear a config, destroy an
|
|
4
|
+
* environment). Built on the same `useConfirm()` singleton + `useToast()` the board delete
|
|
5
|
+
* path uses, so every destructive affordance across the settings/connection surfaces routes
|
|
6
|
+
* through ONE confirm-then-mutate + toast path rather than each re-inventing its own copy.
|
|
7
|
+
*
|
|
8
|
+
* A call site becomes:
|
|
9
|
+
*
|
|
10
|
+
* const { confirmAction, toastDone } = useConfirmAction()
|
|
11
|
+
* async function disconnect() {
|
|
12
|
+
* if (!(await confirmAction('disconnect', providerName))) return
|
|
13
|
+
* await store.remove()
|
|
14
|
+
* toastDone('disconnect', providerName)
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* The copy is generic (`common.confirm.*` / `common.toast.*`) with the target's name
|
|
18
|
+
* interpolated, so the irreversibility warning is translated once per locale — not re-worded
|
|
19
|
+
* per surface. `name` is a short noun for the target (a brand like "Slack", a data value like
|
|
20
|
+
* the invite email, or a feature noun like "the test environment").
|
|
21
|
+
*/
|
|
22
|
+
type ConfirmShape = 'disconnect' | 'remove' | 'revoke' | 'clear' | 'destroy'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-shape copy + iconography. An exhaustive `Record<ConfirmShape, …>` (not an inline
|
|
26
|
+
* template key) so adding a shape without wiring its copy is a typecheck failure — the
|
|
27
|
+
* sanctioned guard for enum→message-key lookups the tier-1 typed-keys check can't see.
|
|
28
|
+
*/
|
|
29
|
+
const SHAPE_META: Record<
|
|
30
|
+
ConfirmShape,
|
|
31
|
+
{ titleKey: string; bodyKey: string; labelKey: string; icon: string; toastKey: string }
|
|
32
|
+
> = {
|
|
33
|
+
disconnect: {
|
|
34
|
+
titleKey: 'common.confirm.titles.disconnect',
|
|
35
|
+
bodyKey: 'common.confirm.reconnectHint',
|
|
36
|
+
labelKey: 'common.disconnect',
|
|
37
|
+
icon: 'i-lucide-unplug',
|
|
38
|
+
toastKey: 'common.toast.disconnected',
|
|
39
|
+
},
|
|
40
|
+
remove: {
|
|
41
|
+
titleKey: 'common.confirm.titles.remove',
|
|
42
|
+
bodyKey: 'common.confirm.irreversible',
|
|
43
|
+
labelKey: 'common.remove',
|
|
44
|
+
icon: 'i-lucide-trash-2',
|
|
45
|
+
toastKey: 'common.toast.removed',
|
|
46
|
+
},
|
|
47
|
+
revoke: {
|
|
48
|
+
titleKey: 'common.confirm.titles.revoke',
|
|
49
|
+
bodyKey: 'common.confirm.irreversible',
|
|
50
|
+
labelKey: 'common.revoke',
|
|
51
|
+
icon: 'i-lucide-ban',
|
|
52
|
+
toastKey: 'common.toast.revoked',
|
|
53
|
+
},
|
|
54
|
+
clear: {
|
|
55
|
+
titleKey: 'common.confirm.titles.clear',
|
|
56
|
+
// A cleared config/connection is re-enterable, so warn it must be reconfigured — not
|
|
57
|
+
// the harsher `irreversible` copy the remove/revoke/destroy shapes use.
|
|
58
|
+
bodyKey: 'common.confirm.reconfigureHint',
|
|
59
|
+
labelKey: 'common.clear',
|
|
60
|
+
icon: 'i-lucide-eraser',
|
|
61
|
+
toastKey: 'common.toast.cleared',
|
|
62
|
+
},
|
|
63
|
+
destroy: {
|
|
64
|
+
titleKey: 'common.confirm.titles.destroy',
|
|
65
|
+
bodyKey: 'common.confirm.irreversible',
|
|
66
|
+
labelKey: 'common.destroy',
|
|
67
|
+
icon: 'i-lucide-trash-2',
|
|
68
|
+
toastKey: 'common.toast.destroyed',
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useConfirmAction() {
|
|
73
|
+
const { confirm } = useConfirm()
|
|
74
|
+
const toast = useToast()
|
|
75
|
+
const { t } = useI18n()
|
|
76
|
+
|
|
77
|
+
/** Prompt before a destructive action against `name`. Resolves `true` only if confirmed. */
|
|
78
|
+
async function confirmAction(shape: ConfirmShape, name: string): Promise<boolean> {
|
|
79
|
+
const meta = SHAPE_META[shape]
|
|
80
|
+
return confirm({
|
|
81
|
+
title: t(meta.titleKey, { name }),
|
|
82
|
+
description: t(meta.bodyKey),
|
|
83
|
+
variant: 'destructive',
|
|
84
|
+
confirmLabel: t(meta.labelKey),
|
|
85
|
+
icon: meta.icon,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Toast the completed destructive action (call only on real success). */
|
|
90
|
+
function toastDone(shape: ConfirmShape, name: string): void {
|
|
91
|
+
const meta = SHAPE_META[shape]
|
|
92
|
+
toast.add({ title: t(meta.toastKey, { name }), color: 'success', icon: 'i-lucide-check' })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { confirmAction, toastDone }
|
|
96
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The app-wide keyboard shortcuts, registered ONCE from `pages/index.vue` (mirroring the
|
|
5
|
+
* ⌘K handler in `CommandBar.vue`). Keeping a single global listener — rather than one per
|
|
6
|
+
* component — is what stops N handlers each firing a delete.
|
|
7
|
+
*
|
|
8
|
+
* · Escape — deselect the current block / close the inspector, but ONLY when no modal
|
|
9
|
+
* is open (every modal is a `UModal` with `role="dialog"`, which already
|
|
10
|
+
* handles its own Escape, so we must not also steal it).
|
|
11
|
+
* · Delete — delete the selected block, through the SAME confirm-gated path the
|
|
12
|
+
* inspector button uses (`useBlockDeletion`). Guarded so it never fires
|
|
13
|
+
* while the user is typing in a field. NOTE: only `Delete`, deliberately
|
|
14
|
+
* NOT `Backspace` — `Backspace` collides with "navigate back" muscle memory
|
|
15
|
+
* and would delete the selected block from anywhere on the board.
|
|
16
|
+
* · ? — toggle the keyboard-shortcuts cheatsheet.
|
|
17
|
+
*/
|
|
18
|
+
export function useKeyboardShortcuts(): void {
|
|
19
|
+
const ui = useUiStore()
|
|
20
|
+
const board = useBoardStore()
|
|
21
|
+
const { deleteBlock } = useBlockDeletion()
|
|
22
|
+
|
|
23
|
+
/** A modal (UModal) is on screen — let it own the keyboard; don't run global shortcuts. */
|
|
24
|
+
function modalOpen(): boolean {
|
|
25
|
+
return ui.commandBarOpen || !!document.querySelector('[role="dialog"]')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The event originates from a text field, so printable/Delete keys are edits, not shortcuts. */
|
|
29
|
+
function isEditableTarget(e: KeyboardEvent): boolean {
|
|
30
|
+
const el = e.target as HTMLElement | null
|
|
31
|
+
if (!el) return false
|
|
32
|
+
const tag = el.tagName
|
|
33
|
+
return (
|
|
34
|
+
tag === 'INPUT' ||
|
|
35
|
+
tag === 'TEXTAREA' ||
|
|
36
|
+
tag === 'SELECT' ||
|
|
37
|
+
el.isContentEditable ||
|
|
38
|
+
!!el.closest('input, textarea, select, [contenteditable="true"]')
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onKeydown(e: KeyboardEvent) {
|
|
43
|
+
// "?" — toggle the cheatsheet. Shift+/ on most layouts; guard against typing. The
|
|
44
|
+
// cheatsheet is itself a modal, so allow the toggle to close it (a plain `modalOpen()`
|
|
45
|
+
// guard would trap it open, since "?" could then only ever open, never close).
|
|
46
|
+
if (e.key === '?' && !isEditableTarget(e) && (!modalOpen() || ui.shortcutsHelpOpen)) {
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
ui.toggleShortcutsHelp()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (e.key === 'Escape') {
|
|
53
|
+
// A modal owns Escape (it closes itself); only deselect when nothing is open.
|
|
54
|
+
if (modalOpen()) return
|
|
55
|
+
if (ui.selectedBlockId) {
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
ui.select(null)
|
|
58
|
+
}
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (e.key === 'Delete') {
|
|
63
|
+
// Never destroy a block while the user is editing a title/description/query.
|
|
64
|
+
if (isEditableTarget(e) || modalOpen()) return
|
|
65
|
+
const id = ui.selectedBlockId
|
|
66
|
+
if (!id) return
|
|
67
|
+
const block = board.getBlock(id)
|
|
68
|
+
if (!block) return
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
void deleteBlock(block)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onMounted(() => window.addEventListener('keydown', onKeydown))
|
|
75
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
|
76
|
+
}
|
|
@@ -46,6 +46,8 @@ const CONFLICT_TITLE_KEYS: Record<
|
|
|
46
46
|
bootstrap_not_retryable: 'errors.conflict.title.bootstrap_not_retryable',
|
|
47
47
|
bootstrap_reference_missing: 'errors.conflict.title.bootstrap_reference_missing',
|
|
48
48
|
provision_type_unhandled: 'errors.conflict.title.provision_type_unhandled',
|
|
49
|
+
preset_unsatisfiable: 'errors.conflict.title.preset_unsatisfiable',
|
|
50
|
+
visual_pipeline_no_frontend: 'errors.conflict.title.visual_pipeline_no_frontend',
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -133,12 +133,32 @@ export function useWorkspaceStream() {
|
|
|
133
133
|
|
|
134
134
|
socket.onopen = () => {
|
|
135
135
|
attempt = 0
|
|
136
|
-
connected
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
136
|
+
// Resync on (re)connect BEFORE announcing `connected`: any event missed while
|
|
137
|
+
// disconnected is reconciled first. The snapshot carries `bootstrapJobs` +
|
|
138
|
+
// executions, so one refresh rehydrates agentRuns too — a missed terminal event
|
|
139
|
+
// (e.g. a container eviction that failed the run) can't leave a frame stuck on a
|
|
140
|
+
// stale "bootstrapping…" badge.
|
|
141
|
+
//
|
|
142
|
+
// We flip `connected` only AFTER that refresh settles so it means "connected AND
|
|
143
|
+
// reconciled". Otherwise `board.hydrate`/`agentRuns.hydrate` reconcile with a
|
|
144
|
+
// snapshot fetched at connect time, which — under load — can resolve AFTER a fresh
|
|
145
|
+
// live event and clobber it: e.g. `board.hydrate` REPLACES the block list and drops
|
|
146
|
+
// a just-created provisional bootstrap frame the stale snapshot never saw, so its
|
|
147
|
+
// live "bootstrapping…" badge flickers out with no further board event to restore
|
|
148
|
+
// it. Anything acting on a `connected` board (a user, or an e2e spec gating on
|
|
149
|
+
// `data-connected`) then does so only after this reconcile, so a lagging resync
|
|
150
|
+
// can't drop the state that action produces. `connected` is still set on failure
|
|
151
|
+
// (we ARE connected; a transient refresh error must not wedge the indicator/tests).
|
|
152
|
+
void workspace
|
|
153
|
+
.refresh()
|
|
154
|
+
.catch(() => {})
|
|
155
|
+
.finally(() => {
|
|
156
|
+
// A workspace switch (or stop()) may have happened while the refresh was in
|
|
157
|
+
// flight — don't announce a connection for a socket we've since abandoned.
|
|
158
|
+
if (!stopped && socket && workspace.workspaceId === workspaceId) {
|
|
159
|
+
connected.value = true
|
|
160
|
+
}
|
|
161
|
+
})
|
|
142
162
|
}
|
|
143
163
|
socket.onmessage = (e) => onMessage(typeof e.data === 'string' ? e.data : '')
|
|
144
164
|
socket.onclose = () => {
|
package/app/pages/index.vue
CHANGED
|
@@ -7,6 +7,7 @@ import TranslationWarningBanner from '~/components/layout/TranslationWarningBann
|
|
|
7
7
|
import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
|
|
8
8
|
import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
|
|
9
9
|
import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
|
|
10
|
+
import InfraSetupBanner from '~/components/layout/InfraSetupBanner.vue'
|
|
10
11
|
// Always-mounted, fast-path surfaces (opened frequently during a run / board edits, or
|
|
11
12
|
// store-driven so they must react from anywhere — kept eager for snappy open/close).
|
|
12
13
|
import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
|
|
@@ -18,6 +19,8 @@ import AddTaskModal from '~/components/board/AddTaskModal.vue'
|
|
|
18
19
|
import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
|
|
19
20
|
import CommandBar from '~/components/layout/CommandBar.vue'
|
|
20
21
|
import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
|
|
22
|
+
import ConfirmDialog from '~/components/common/ConfirmDialog.vue'
|
|
23
|
+
import KeyboardShortcutsHelp from '~/components/common/KeyboardShortcutsHelp.vue'
|
|
21
24
|
|
|
22
25
|
// Heavy, rarely-open panels — code-split into their own chunks via defineAsyncComponent
|
|
23
26
|
// and mounted only while their ui open-flag is set (the v-if gates in the template), so
|
|
@@ -113,6 +116,10 @@ const models = useModelsStore()
|
|
|
113
116
|
const ui = useUiStore()
|
|
114
117
|
const aiReadiness = useAiReadiness()
|
|
115
118
|
|
|
119
|
+
// App-wide keyboard shortcuts (Escape to deselect, Delete to remove the selected block, ?
|
|
120
|
+
// for the cheatsheet). Registered ONCE here so a single global listener owns them.
|
|
121
|
+
useKeyboardShortcuts()
|
|
122
|
+
|
|
116
123
|
// Load the board from the backend before rendering it.
|
|
117
124
|
onMounted(() => {
|
|
118
125
|
void workspace.init()
|
|
@@ -141,6 +148,8 @@ watch(
|
|
|
141
148
|
autoOpenedSetup.value = false
|
|
142
149
|
autoOpenedPreset.value = false
|
|
143
150
|
ui.resetAiOnboarding()
|
|
151
|
+
// Infra-setup banner session dismissals are per-workspace too — clear them on switch.
|
|
152
|
+
ui.resetInfraSetupDismissals()
|
|
144
153
|
// A different board has its own pipeline library, so re-arm the once-per-session advisory.
|
|
145
154
|
ui.pipelineHealthSeen = false
|
|
146
155
|
}
|
|
@@ -242,10 +251,23 @@ watch(
|
|
|
242
251
|
<TranslationWarningBanner />
|
|
243
252
|
<!-- Local-mode setup prompt (missing GitHub PAT); floats over whatever is shown below. -->
|
|
244
253
|
<GitHubPatBanner />
|
|
245
|
-
<!--
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
254
|
+
<!-- Stacked advisory banners: one click-through column so concurrent prompts never draw on
|
|
255
|
+
top of each other (a fresh, unconfigured deployment can raise all three at once — no AI
|
|
256
|
+
model + no runner pool + no storage). The wrapper is `pointer-events-none`; each banner
|
|
257
|
+
re-enables pointer events on its own card, so the empty strip never intercepts clicks on
|
|
258
|
+
the board chrome underneath.
|
|
259
|
+
- AI-readiness (no usable model source, or default preset uses unavailable models).
|
|
260
|
+
- Infrastructure provider (env/runner-pool wired but missing mandatory config).
|
|
261
|
+
- Infra-setup (this deployment needs an executor / test env / storage the operator hasn't
|
|
262
|
+
defined yet, so a class of agents can't run). -->
|
|
263
|
+
<div
|
|
264
|
+
v-if="workspace.ready && !needsGitHubInstall && !githubProbePending"
|
|
265
|
+
class="pointer-events-none absolute inset-x-0 top-0 z-40 flex flex-col items-center gap-2 px-4 pt-4"
|
|
266
|
+
>
|
|
267
|
+
<AiProvidersBanner />
|
|
268
|
+
<ProviderConfigBanner />
|
|
269
|
+
<InfraSetupBanner />
|
|
270
|
+
</div>
|
|
249
271
|
|
|
250
272
|
<!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
|
|
251
273
|
<div
|
|
@@ -307,6 +329,8 @@ watch(
|
|
|
307
329
|
<AddTaskModal />
|
|
308
330
|
<CommandBar />
|
|
309
331
|
<PersonalCredentialModal />
|
|
332
|
+
<ConfirmDialog />
|
|
333
|
+
<KeyboardShortcutsHelp />
|
|
310
334
|
|
|
311
335
|
<!-- Lazy panels: mounted only while their ui open-flag is set, so each loads on
|
|
312
336
|
first open (its own chunk) rather than bloating the initial bundle. -->
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import type { BootstrapJob } from '~/types/domain'
|
|
3
|
+
import { useAgentRunsStore } from '~/stores/agentRuns'
|
|
4
|
+
|
|
5
|
+
/** Minimal BootstrapJob factory — only the fields the store's reconcile logic touches. */
|
|
6
|
+
function job(id: string, over: Partial<BootstrapJob> = {}): BootstrapJob {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
workspaceId: 'ws_test',
|
|
10
|
+
referenceArchitectureId: null,
|
|
11
|
+
referenceArchitectureName: null,
|
|
12
|
+
repoName: id,
|
|
13
|
+
repoOwner: null,
|
|
14
|
+
repoUrl: null,
|
|
15
|
+
instructions: '',
|
|
16
|
+
status: 'running',
|
|
17
|
+
blockId: `blk_${id}`,
|
|
18
|
+
subtasks: null,
|
|
19
|
+
error: null,
|
|
20
|
+
failure: null,
|
|
21
|
+
createdAt: 1,
|
|
22
|
+
updatedAt: 1,
|
|
23
|
+
...over,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('agentRuns store — monotonic bootstrap reconcile', () => {
|
|
28
|
+
let store: ReturnType<typeof useAgentRunsStore>
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
store = useAgentRunsStore()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('hydrate does NOT regress a run a newer live event already advanced', () => {
|
|
34
|
+
// A live `bootstrap: failed` event landed first (newer updatedAt).
|
|
35
|
+
store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
|
|
36
|
+
// A lagging `workspace.refresh()` then hydrates a STALE snapshot that still saw
|
|
37
|
+
// the run as `running` (older updatedAt) — it must NOT clobber the terminal state.
|
|
38
|
+
store.hydrate([job('j1', { status: 'running', updatedAt: 2 })], 'ws_test')
|
|
39
|
+
expect(store.bootstrapJobs[0]!.status).toBe('failed')
|
|
40
|
+
expect(store.byBlock.blk_j1!.status).toBe('failed')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('hydrate does NOT drop a live-added run the stale snapshot never saw', () => {
|
|
44
|
+
// The on-connect resync fires a snapshot fetch BEFORE the bootstrap starts, then a live
|
|
45
|
+
// `bootstrap: failed` event lands while that fetch is in flight. When the (older) snapshot
|
|
46
|
+
// finally resolves it does not contain the run at all — mapping over the snapshot alone
|
|
47
|
+
// would silently drop it, stranding the frame with no further event to correct it.
|
|
48
|
+
store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
|
|
49
|
+
store.hydrate([], 'ws_test') // stale snapshot: fetched before the run existed
|
|
50
|
+
expect(store.bootstrapJobs[0]?.status).toBe('failed')
|
|
51
|
+
expect(store.byBlock.blk_j1!.status).toBe('failed')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('hydrate DROPS a cached run from a different workspace (board switch starts clean)', () => {
|
|
55
|
+
store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5, workspaceId: 'ws_other' }))
|
|
56
|
+
// Switching to ws_test: its snapshot must not leak the previous board's run.
|
|
57
|
+
store.hydrate([job('j2', { workspaceId: 'ws_test' })], 'ws_test')
|
|
58
|
+
expect(store.bootstrapJobs.map((j) => j.id)).toEqual(['j2'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('hydrate DOES apply a genuinely newer snapshot', () => {
|
|
62
|
+
store.upsertBootstrap(job('j1', { status: 'running', updatedAt: 2 }))
|
|
63
|
+
store.hydrate([job('j1', { status: 'succeeded', updatedAt: 9 })], 'ws_test')
|
|
64
|
+
expect(store.bootstrapJobs[0]!.status).toBe('succeeded')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('upsertBootstrap ignores an older/out-of-order event but applies newer/equal', () => {
|
|
68
|
+
store.upsertBootstrap(job('j1', { status: 'failed', updatedAt: 5 }))
|
|
69
|
+
store.upsertBootstrap(job('j1', { status: 'running', updatedAt: 3 })) // stale → ignored
|
|
70
|
+
expect(store.bootstrapJobs[0]!.status).toBe('failed')
|
|
71
|
+
store.upsertBootstrap(job('j1', { status: 'succeeded', updatedAt: 5 })) // equal → applied
|
|
72
|
+
expect(store.bootstrapJobs[0]!.status).toBe('succeeded')
|
|
73
|
+
})
|
|
74
|
+
})
|
package/app/stores/agentRuns.ts
CHANGED
|
@@ -59,9 +59,34 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
|
|
|
59
59
|
*/
|
|
60
60
|
const envConfigRepairJobs = ref<EnvConfigRepairJob[]>([])
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Reconcile the cached bootstrap runs with a server snapshot for `workspaceId`. A snapshot is
|
|
64
|
+
* authoritative EXCEPT where a live event has already advanced (or ADDED) a run past what this
|
|
65
|
+
* (possibly stale) read observed: a `board` event triggers a debounced `workspace.refresh()`,
|
|
66
|
+
* and the stream's on-(re)connect resync also refetches — either read can resolve AFTER a newer
|
|
67
|
+
* `bootstrap` event already landed. Two clobber hazards, both handled here:
|
|
68
|
+
* - REGRESS: a run present in BOTH the snapshot and the cache — keep the newer-by-`updatedAt`
|
|
69
|
+
* version so a lagging refresh can't revert a `failed`/`succeeded` run to `running`.
|
|
70
|
+
* - DROP: a run a live event just ADDED that the (older) snapshot never saw — mapping over the
|
|
71
|
+
* snapshot alone would silently drop it, and a terminal bootstrap emits nothing further, so
|
|
72
|
+
* the frame would be stranded on a stale "bootstrapping…" badge with no event to correct it.
|
|
73
|
+
* Preserve such cached runs. Scoped to `workspaceId` (bootstrap runs carry it), so a board
|
|
74
|
+
* SWITCH — whose snapshot is for a different workspace — still discards the previous board's
|
|
75
|
+
* runs instead of leaking them onto the new board.
|
|
76
|
+
*/
|
|
77
|
+
function hydrate(jobs: BootstrapJob[], workspaceId: string) {
|
|
78
|
+
const incomingIds = new Set(jobs.map((j) => j.id))
|
|
79
|
+
const held = new Map(bootstrapJobs.value.map((j) => [j.id, j]))
|
|
80
|
+
const reconciled = jobs.map((incoming) => {
|
|
81
|
+
const current = held.get(incoming.id)
|
|
82
|
+
return current && current.updatedAt > incoming.updatedAt ? current : incoming
|
|
83
|
+
})
|
|
84
|
+
// Live-added runs the snapshot hasn't observed yet — keep only this workspace's (a switch
|
|
85
|
+
// starts clean), so a resync that races a fresh `bootstrap` event can't drop the run.
|
|
86
|
+
const preserved = [...held.values()].filter(
|
|
87
|
+
(j) => !incomingIds.has(j.id) && j.workspaceId === workspaceId,
|
|
88
|
+
)
|
|
89
|
+
bootstrapJobs.value = [...reconciled, ...preserved].sort((a, b) => b.createdAt - a.createdAt)
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
/** Replace the cached env-config-repair runs with a server snapshot. */
|
|
@@ -92,8 +117,11 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
|
|
|
92
117
|
*/
|
|
93
118
|
function upsertBootstrap(job: BootstrapJob) {
|
|
94
119
|
const i = bootstrapJobs.value.findIndex((j) => j.id === job.id)
|
|
95
|
-
|
|
96
|
-
|
|
120
|
+
// Monotonic by `updatedAt`: never let a stale/out-of-order event regress a run a
|
|
121
|
+
// newer one already advanced (same guard as {@link hydrate}).
|
|
122
|
+
if (i >= 0) {
|
|
123
|
+
if (job.updatedAt >= bootstrapJobs.value[i]!.updatedAt) bootstrapJobs.value[i] = job
|
|
124
|
+
} else bootstrapJobs.value.unshift(job)
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
/**
|
package/app/stores/ui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
|
-
import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
|
|
3
|
+
import type { DocumentSourceKind, TaskSourceKind, LodLevel, InfraSetupArea } from '~/types/domain'
|
|
4
4
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
5
5
|
import { zoomToLod } from '~/composables/useSemanticZoom'
|
|
6
6
|
import { useExecutionStore } from '~/stores/execution'
|
|
@@ -105,6 +105,9 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
105
105
|
// Command bar (⌘K) — searchable launcher for every navbar action.
|
|
106
106
|
const commandBarOpen = ref(false)
|
|
107
107
|
|
|
108
|
+
// Keyboard-shortcuts cheatsheet (?) — a modal listing every global shortcut.
|
|
109
|
+
const shortcutsHelpOpen = ref(false)
|
|
110
|
+
|
|
108
111
|
// Mobile navigation drawer: on compact (< lg) viewports the SideBar is an
|
|
109
112
|
// off-canvas drawer toggled by a hamburger; on lg+ it is a static aside and this
|
|
110
113
|
// flag is ignored. Closed on any nav action so the board is revealed immediately.
|
|
@@ -187,6 +190,19 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
187
190
|
const aiSetupDismissed = ref(false)
|
|
188
191
|
const aiPresetDismissed = ref(false)
|
|
189
192
|
|
|
193
|
+
// Infra-setup banner: per-SESSION dismissals, one flag per area, cleared on workspace switch
|
|
194
|
+
// exactly like the AI-onboarding flags (a dismissal in one workspace must not suppress the
|
|
195
|
+
// independent prompt for another). The PERMANENT "don't notify me again" dismissal is per-USER
|
|
196
|
+
// and persists in localStorage from the banner component; this only covers "hide for now".
|
|
197
|
+
const infraSetupSessionDismissed = ref<InfraSetupArea[]>([])
|
|
198
|
+
function dismissInfraSetupForSession(area: InfraSetupArea) {
|
|
199
|
+
if (!infraSetupSessionDismissed.value.includes(area))
|
|
200
|
+
infraSetupSessionDismissed.value = [...infraSetupSessionDismissed.value, area]
|
|
201
|
+
}
|
|
202
|
+
function resetInfraSetupDismissals() {
|
|
203
|
+
infraSetupSessionDismissed.value = []
|
|
204
|
+
}
|
|
205
|
+
|
|
190
206
|
// Dedicated result-view overlay: a step whose agent kind declares a bespoke
|
|
191
207
|
// visualization (via the archetype's `resultView`) opens here instead of the generic
|
|
192
208
|
// prose step-detail panel. `view` is the registry id (e.g. 'requirements-review');
|
|
@@ -446,6 +462,15 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
446
462
|
function toggleCommandBar() {
|
|
447
463
|
commandBarOpen.value = !commandBarOpen.value
|
|
448
464
|
}
|
|
465
|
+
function openShortcutsHelp() {
|
|
466
|
+
shortcutsHelpOpen.value = true
|
|
467
|
+
}
|
|
468
|
+
function closeShortcutsHelp() {
|
|
469
|
+
shortcutsHelpOpen.value = false
|
|
470
|
+
}
|
|
471
|
+
function toggleShortcutsHelp() {
|
|
472
|
+
shortcutsHelpOpen.value = !shortcutsHelpOpen.value
|
|
473
|
+
}
|
|
449
474
|
function openMobileNav() {
|
|
450
475
|
mobileNavOpen.value = true
|
|
451
476
|
}
|
|
@@ -754,6 +779,7 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
754
779
|
slackOpen,
|
|
755
780
|
fragmentLibraryOpen,
|
|
756
781
|
commandBarOpen,
|
|
782
|
+
shortcutsHelpOpen,
|
|
757
783
|
mobileNavOpen,
|
|
758
784
|
integrationsOpen,
|
|
759
785
|
cameFromIntegrations,
|
|
@@ -779,6 +805,9 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
779
805
|
aiPresetMismatchOpen,
|
|
780
806
|
aiSetupDismissed,
|
|
781
807
|
aiPresetDismissed,
|
|
808
|
+
infraSetupSessionDismissed,
|
|
809
|
+
dismissInfraSetupForSession,
|
|
810
|
+
resetInfraSetupDismissals,
|
|
782
811
|
resultView,
|
|
783
812
|
closeResultView,
|
|
784
813
|
stepDetail,
|
|
@@ -829,6 +858,9 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
829
858
|
openCommandBar,
|
|
830
859
|
closeCommandBar,
|
|
831
860
|
toggleCommandBar,
|
|
861
|
+
openShortcutsHelp,
|
|
862
|
+
closeShortcutsHelp,
|
|
863
|
+
toggleShortcutsHelp,
|
|
832
864
|
openMobileNav,
|
|
833
865
|
closeMobileNav,
|
|
834
866
|
toggleMobileNav,
|
package/app/stores/workspace.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
-
import type { SpendStatus, Workspace, WorkspaceSnapshot } from '~/types/domain'
|
|
3
|
+
import type { InfraSetup, SpendStatus, Workspace, WorkspaceSnapshot } from '~/types/domain'
|
|
4
4
|
import { useAccountsStore } from '~/stores/accounts'
|
|
5
5
|
import { useBoardStore } from '~/stores/board'
|
|
6
6
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
@@ -49,6 +49,12 @@ export const useWorkspaceStore = defineStore(
|
|
|
49
49
|
const error = ref<string | null>(null)
|
|
50
50
|
/** Latest spend-safeguard status from the server (null until first load). */
|
|
51
51
|
const spend = ref<SpendStatus | null>(null)
|
|
52
|
+
/**
|
|
53
|
+
* Per-area infrastructure-setup status (ephemeral environments / agent executor / binary
|
|
54
|
+
* storage) from the snapshot, driving the infra-setup banner. Null on an older backend that
|
|
55
|
+
* doesn't compute it (⇒ no banner).
|
|
56
|
+
*/
|
|
57
|
+
const infraSetup = ref<InfraSetup | null>(null)
|
|
52
58
|
|
|
53
59
|
/** The boards belonging to the active account (all boards when auth is off). */
|
|
54
60
|
const accountWorkspaces = computed(() => {
|
|
@@ -78,6 +84,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
78
84
|
}
|
|
79
85
|
workspaceId.value = snapshot.workspace.id
|
|
80
86
|
spend.value = snapshot.spend ?? null
|
|
87
|
+
infraSetup.value = snapshot.infraSetup ?? null
|
|
81
88
|
// Keep the board list in step (e.g. a freshly created board, or a rename).
|
|
82
89
|
const i = workspaces.value.findIndex((w) => w.id === snapshot.workspace.id)
|
|
83
90
|
if (i >= 0) workspaces.value[i] = snapshot.workspace
|
|
@@ -85,7 +92,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
85
92
|
useBoardStore().hydrate(snapshot.blocks)
|
|
86
93
|
usePipelinesStore().hydrate(snapshot.pipelines, snapshot.pipelineCatalogVersions)
|
|
87
94
|
useExecutionStore().hydrate(snapshot.executions)
|
|
88
|
-
useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [])
|
|
95
|
+
useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
|
|
89
96
|
useAgentRunsStore().hydrateEnvConfigRepair(snapshot.envConfigRepairJobs ?? [])
|
|
90
97
|
useNotificationsStore().hydrate(snapshot.notifications ?? [])
|
|
91
98
|
useMergePresetsStore().hydrate(
|
|
@@ -236,6 +243,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
236
243
|
ready,
|
|
237
244
|
error,
|
|
238
245
|
spend,
|
|
246
|
+
infraSetup,
|
|
239
247
|
init,
|
|
240
248
|
switchTo,
|
|
241
249
|
selectAccount,
|
package/app/types/domain.ts
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { frameAllowsVisualPipeline, pipelineHasVisualStep } from '@cat-factory/contracts'
|
|
2
|
+
import type { Block, Pipeline } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
// Surface counterpart to the backend's slice-4c run-start gate: a pipeline with a visual step
|
|
5
|
+
// (`tester-ui` / `visual-confirmation`) may run only on a frame with a UI to exercise — a
|
|
6
|
+
// `frontend` frame, or a frame a `frontend` frame links to. The SPA hides such pipelines from
|
|
7
|
+
// the pickers where they can't run so a user never picks one the backend would refuse. Both
|
|
8
|
+
// sides share the pure predicates from `@cat-factory/contracts`, so the surface can't drift from
|
|
9
|
+
// the gate.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether `pipeline` may run on a task under `frame`. A non-visual pipeline is always allowed;
|
|
13
|
+
* a visual one only when the frame has a UI (see {@link frameAllowsVisualPipeline}). `blocks` is
|
|
14
|
+
* the board's block list, used to find frontend→service links.
|
|
15
|
+
*/
|
|
16
|
+
export function pipelineAllowedForFrame(
|
|
17
|
+
pipeline: Pipeline,
|
|
18
|
+
frame: Block | undefined,
|
|
19
|
+
blocks: readonly Block[],
|
|
20
|
+
): boolean {
|
|
21
|
+
return !pipelineHasVisualStep(pipeline) || frameAllowsVisualPipeline(frame, blocks)
|
|
22
|
+
}
|