@cat-factory/app 0.37.3 → 0.39.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/AuthGate.vue +8 -0
- package/app/components/auth/LoginScreen.vue +86 -8
- package/app/components/auth/ResetPasswordScreen.vue +106 -0
- package/app/components/pipeline/PipelineHealthModal.vue +201 -0
- package/app/composables/api/auth.ts +11 -0
- package/app/composables/api/board.ts +6 -0
- package/app/composables/usePipelineHealth.spec.ts +143 -0
- package/app/composables/usePipelineHealth.ts +140 -0
- package/app/pages/index.vue +20 -0
- package/app/pages/reset-password.vue +7 -0
- package/app/stores/auth.ts +12 -0
- package/app/stores/pipelines.ts +24 -2
- package/app/stores/ui.ts +26 -0
- package/app/stores/workspace.ts +1 -1
- package/app/utils/catalog.ts +34 -0
- package/package.json +2 -2
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
2
3
|
import LoginScreen from '~/components/auth/LoginScreen.vue'
|
|
3
4
|
|
|
4
5
|
// Resolves auth state once on mount, then either renders the app (auth off, or
|
|
5
6
|
// on with a signed-in user) or the login screen. The board's own bootstrap runs
|
|
6
7
|
// inside the default slot, so it only fires once the user is allowed in.
|
|
7
8
|
const auth = useAuthStore()
|
|
9
|
+
const route = useRoute()
|
|
10
|
+
|
|
11
|
+
// The password-reset page is public: a recipient of an emailed reset link is signed
|
|
12
|
+
// out, so it must render even when auth is required and there's no user.
|
|
13
|
+
const isPublicRoute = computed(() => route.path === '/reset-password')
|
|
8
14
|
|
|
9
15
|
onMounted(() => auth.bootstrap())
|
|
10
16
|
</script>
|
|
@@ -18,6 +24,8 @@ onMounted(() => auth.bootstrap())
|
|
|
18
24
|
<span class="text-sm">Loading…</span>
|
|
19
25
|
</div>
|
|
20
26
|
|
|
27
|
+
<slot v-else-if="isPublicRoute" />
|
|
28
|
+
|
|
21
29
|
<LoginScreen v-else-if="auth.required && !auth.user" />
|
|
22
30
|
|
|
23
31
|
<slot v-else />
|
|
@@ -12,13 +12,17 @@ const invite = computed(() => {
|
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
// Password form: signup creates a new user (invite or allowed-email-domain gated),
|
|
15
|
-
// login authenticates an existing one. Default to login;
|
|
16
|
-
|
|
15
|
+
// login authenticates an existing one, forgot requests a reset link. Default to login;
|
|
16
|
+
// flip to signup when invited.
|
|
17
|
+
const mode = ref<'login' | 'signup' | 'forgot'>(invite.value ? 'signup' : 'login')
|
|
17
18
|
const email = ref('')
|
|
18
19
|
const password = ref('')
|
|
19
20
|
const name = ref('')
|
|
20
21
|
const error = ref<string | null>(null)
|
|
21
22
|
const busy = ref(false)
|
|
23
|
+
// Set once a reset link has been requested, so we can show a generic confirmation
|
|
24
|
+
// (we never reveal whether the email is registered).
|
|
25
|
+
const forgotSent = ref(false)
|
|
22
26
|
|
|
23
27
|
async function submitPassword() {
|
|
24
28
|
error.value = null
|
|
@@ -44,6 +48,27 @@ async function submitPassword() {
|
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
async function submitForgot() {
|
|
52
|
+
error.value = null
|
|
53
|
+
busy.value = true
|
|
54
|
+
try {
|
|
55
|
+
await auth.forgotPassword(email.value)
|
|
56
|
+
forgotSent.value = true
|
|
57
|
+
} catch {
|
|
58
|
+
// The request endpoint is generic by design; only an infra error lands here.
|
|
59
|
+
error.value = 'Something went wrong. Please try again.'
|
|
60
|
+
} finally {
|
|
61
|
+
busy.value = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Switch modes, clearing any transient form state. */
|
|
66
|
+
function setMode(next: 'login' | 'signup' | 'forgot') {
|
|
67
|
+
mode.value = next
|
|
68
|
+
error.value = null
|
|
69
|
+
forgotSent.value = false
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
const showOAuthDivider = computed(
|
|
48
73
|
() => auth.providers.password && (auth.providers.github || auth.providers.google),
|
|
49
74
|
)
|
|
@@ -58,12 +83,15 @@ const showOAuthDivider = computed(
|
|
|
58
83
|
<UIcon name="i-lucide-layout-dashboard" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
|
|
59
84
|
<h1 class="mb-1 text-lg font-semibold text-white">Architecture Board</h1>
|
|
60
85
|
<p class="text-sm text-slate-400">
|
|
61
|
-
|
|
86
|
+
<template v-if="mode === 'forgot'">Reset your password.</template>
|
|
87
|
+
<template v-else>{{
|
|
88
|
+
invite ? 'Accept your invitation to continue.' : 'Sign in to continue.'
|
|
89
|
+
}}</template>
|
|
62
90
|
</p>
|
|
63
91
|
</div>
|
|
64
92
|
|
|
65
93
|
<!-- OAuth providers -->
|
|
66
|
-
<div class="space-y-2">
|
|
94
|
+
<div v-if="mode !== 'forgot'" class="space-y-2">
|
|
67
95
|
<UButton
|
|
68
96
|
v-if="auth.providers.github"
|
|
69
97
|
block
|
|
@@ -87,12 +115,19 @@ const showOAuthDivider = computed(
|
|
|
87
115
|
</UButton>
|
|
88
116
|
</div>
|
|
89
117
|
|
|
90
|
-
<div
|
|
118
|
+
<div
|
|
119
|
+
v-if="showOAuthDivider && mode !== 'forgot'"
|
|
120
|
+
class="my-4 flex items-center gap-3 text-xs text-slate-500"
|
|
121
|
+
>
|
|
91
122
|
<span class="h-px flex-1 bg-slate-800" /> or <span class="h-px flex-1 bg-slate-800" />
|
|
92
123
|
</div>
|
|
93
124
|
|
|
94
125
|
<!-- Email / password -->
|
|
95
|
-
<form
|
|
126
|
+
<form
|
|
127
|
+
v-if="auth.providers.password && mode !== 'forgot'"
|
|
128
|
+
class="space-y-3"
|
|
129
|
+
@submit.prevent="submitPassword"
|
|
130
|
+
>
|
|
96
131
|
<UInput
|
|
97
132
|
v-if="mode === 'signup'"
|
|
98
133
|
v-model="name"
|
|
@@ -126,17 +161,60 @@ const showOAuthDivider = computed(
|
|
|
126
161
|
<p class="text-center text-xs text-slate-400">
|
|
127
162
|
<template v-if="mode === 'login'">
|
|
128
163
|
Need an account?
|
|
129
|
-
<button
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
class="text-indigo-400 hover:underline"
|
|
167
|
+
@click="setMode('signup')"
|
|
168
|
+
>
|
|
130
169
|
Sign up
|
|
131
170
|
</button>
|
|
132
171
|
</template>
|
|
133
172
|
<template v-else>
|
|
134
173
|
Already have an account?
|
|
135
|
-
<button type="button" class="text-indigo-400 hover:underline" @click="
|
|
174
|
+
<button type="button" class="text-indigo-400 hover:underline" @click="setMode('login')">
|
|
136
175
|
Sign in
|
|
137
176
|
</button>
|
|
138
177
|
</template>
|
|
139
178
|
</p>
|
|
179
|
+
<p v-if="mode === 'login'" class="text-center text-xs text-slate-400">
|
|
180
|
+
<button type="button" class="text-indigo-400 hover:underline" @click="setMode('forgot')">
|
|
181
|
+
Forgot password?
|
|
182
|
+
</button>
|
|
183
|
+
</p>
|
|
184
|
+
</form>
|
|
185
|
+
|
|
186
|
+
<!-- Forgot password: request a reset link by email -->
|
|
187
|
+
<form
|
|
188
|
+
v-if="auth.providers.password && mode === 'forgot'"
|
|
189
|
+
class="space-y-3"
|
|
190
|
+
@submit.prevent="submitForgot"
|
|
191
|
+
>
|
|
192
|
+
<template v-if="forgotSent">
|
|
193
|
+
<p class="text-sm text-slate-300">
|
|
194
|
+
If an account exists for that email, a password reset link is on its way. Check your
|
|
195
|
+
inbox.
|
|
196
|
+
</p>
|
|
197
|
+
</template>
|
|
198
|
+
<template v-else>
|
|
199
|
+
<UInput
|
|
200
|
+
v-model="email"
|
|
201
|
+
type="email"
|
|
202
|
+
required
|
|
203
|
+
placeholder="Email"
|
|
204
|
+
icon="i-lucide-at-sign"
|
|
205
|
+
size="lg"
|
|
206
|
+
class="w-full"
|
|
207
|
+
/>
|
|
208
|
+
<p v-if="error" class="text-sm text-rose-400">{{ error }}</p>
|
|
209
|
+
<UButton block size="lg" color="primary" type="submit" :loading="busy">
|
|
210
|
+
Send reset link
|
|
211
|
+
</UButton>
|
|
212
|
+
</template>
|
|
213
|
+
<p class="text-center text-xs text-slate-400">
|
|
214
|
+
<button type="button" class="text-indigo-400 hover:underline" @click="setMode('login')">
|
|
215
|
+
Back to sign in
|
|
216
|
+
</button>
|
|
217
|
+
</p>
|
|
140
218
|
</form>
|
|
141
219
|
</div>
|
|
142
220
|
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
// Standalone full-screen reset form reached from the emailed link
|
|
5
|
+
// (`/reset-password?token=…`). It is a public route (see AuthGate), so a recipient who
|
|
6
|
+
// is signed out can still set a new password. On success we send them to the login.
|
|
7
|
+
const auth = useAuthStore()
|
|
8
|
+
|
|
9
|
+
const token = computed(() => {
|
|
10
|
+
if (typeof window === 'undefined') return ''
|
|
11
|
+
return new URLSearchParams(window.location.search).get('token') || ''
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const password = ref('')
|
|
15
|
+
const confirm = ref('')
|
|
16
|
+
const error = ref<string | null>(null)
|
|
17
|
+
const busy = ref(false)
|
|
18
|
+
const done = ref(false)
|
|
19
|
+
|
|
20
|
+
async function submit() {
|
|
21
|
+
error.value = null
|
|
22
|
+
if (password.value.length < 8) {
|
|
23
|
+
error.value = 'Password must be at least 8 characters.'
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if (password.value !== confirm.value) {
|
|
27
|
+
error.value = 'Passwords do not match.'
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
busy.value = true
|
|
31
|
+
try {
|
|
32
|
+
await auth.resetPassword(token.value, password.value)
|
|
33
|
+
done.value = true
|
|
34
|
+
} catch (e) {
|
|
35
|
+
error.value =
|
|
36
|
+
(e as { data?: { error?: { message?: string } } })?.data?.error?.message ??
|
|
37
|
+
'This password reset link is invalid or has expired.'
|
|
38
|
+
} finally {
|
|
39
|
+
busy.value = false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function goToLogin() {
|
|
44
|
+
if (typeof window !== 'undefined') window.location.assign('/')
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="flex h-screen w-screen items-center justify-center bg-slate-950 text-slate-100">
|
|
50
|
+
<div
|
|
51
|
+
class="w-full max-w-sm rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
|
|
52
|
+
>
|
|
53
|
+
<div class="mb-6 text-center">
|
|
54
|
+
<UIcon name="i-lucide-key-round" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
|
|
55
|
+
<h1 class="mb-1 text-lg font-semibold text-white">Reset password</h1>
|
|
56
|
+
<p class="text-sm text-slate-400">Choose a new password for your account.</p>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<template v-if="done">
|
|
60
|
+
<p class="mb-4 text-sm text-slate-300">
|
|
61
|
+
Your password has been reset. You can now sign in with your new password.
|
|
62
|
+
</p>
|
|
63
|
+
<UButton block size="lg" color="primary" @click="goToLogin">Go to sign in</UButton>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<template v-else-if="!token">
|
|
67
|
+
<p class="mb-4 text-sm text-rose-400">
|
|
68
|
+
This reset link is missing its token. Request a new link from the sign-in screen.
|
|
69
|
+
</p>
|
|
70
|
+
<UButton block size="lg" color="neutral" variant="subtle" @click="goToLogin">
|
|
71
|
+
Back to sign in
|
|
72
|
+
</UButton>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<form v-else class="space-y-3" @submit.prevent="submit">
|
|
76
|
+
<UInput
|
|
77
|
+
v-model="password"
|
|
78
|
+
type="password"
|
|
79
|
+
required
|
|
80
|
+
placeholder="New password"
|
|
81
|
+
icon="i-lucide-lock"
|
|
82
|
+
size="lg"
|
|
83
|
+
class="w-full"
|
|
84
|
+
/>
|
|
85
|
+
<UInput
|
|
86
|
+
v-model="confirm"
|
|
87
|
+
type="password"
|
|
88
|
+
required
|
|
89
|
+
placeholder="Confirm new password"
|
|
90
|
+
icon="i-lucide-lock"
|
|
91
|
+
size="lg"
|
|
92
|
+
class="w-full"
|
|
93
|
+
/>
|
|
94
|
+
<p v-if="error" class="text-sm text-rose-400">{{ error }}</p>
|
|
95
|
+
<UButton block size="lg" color="primary" type="submit" :loading="busy">
|
|
96
|
+
Reset password
|
|
97
|
+
</UButton>
|
|
98
|
+
<p class="text-center text-xs text-slate-400">
|
|
99
|
+
<button type="button" class="text-indigo-400 hover:underline" @click="goToLogin">
|
|
100
|
+
Back to sign in
|
|
101
|
+
</button>
|
|
102
|
+
</p>
|
|
103
|
+
</form>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Startup advisory for unhealthy pipelines. Opened once per session from the board page when
|
|
3
|
+
// `usePipelineHealth` reports any issue. Lists:
|
|
4
|
+
// • invalid pipelines (unknown agent kind / bad shape) — DELETE a custom one, RESEED a built-in;
|
|
5
|
+
// • outdated built-ins (a newer catalog definition is available) — RESEED to adopt it.
|
|
6
|
+
// Detection is client-side (see usePipelineHealth); the actions hit the pipelines store.
|
|
7
|
+
const ui = useUiStore()
|
|
8
|
+
const pipelines = usePipelinesStore()
|
|
9
|
+
const { invalid, outdated, hasIssues } = usePipelineHealth()
|
|
10
|
+
const toast = useToast()
|
|
11
|
+
|
|
12
|
+
const open = computed({
|
|
13
|
+
get: () => ui.pipelineHealthOpen,
|
|
14
|
+
set: (v: boolean) => {
|
|
15
|
+
if (!v) ui.closePipelineHealth()
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// Per-pipeline in-flight ids, so each row's button shows its own spinner.
|
|
20
|
+
const busy = ref<Set<string>>(new Set())
|
|
21
|
+
const isBusy = (id: string) => busy.value.has(id)
|
|
22
|
+
const anyBusy = computed(() => busy.value.size > 0)
|
|
23
|
+
|
|
24
|
+
async function run(id: string, action: () => Promise<unknown>, failTitle: string) {
|
|
25
|
+
busy.value = new Set(busy.value).add(id)
|
|
26
|
+
try {
|
|
27
|
+
await action()
|
|
28
|
+
} catch (e) {
|
|
29
|
+
toast.add({
|
|
30
|
+
title: failTitle,
|
|
31
|
+
description: e instanceof Error ? e.message : String(e),
|
|
32
|
+
icon: 'i-lucide-triangle-alert',
|
|
33
|
+
color: 'error',
|
|
34
|
+
})
|
|
35
|
+
} finally {
|
|
36
|
+
const next = new Set(busy.value)
|
|
37
|
+
next.delete(id)
|
|
38
|
+
busy.value = next
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const reseed = (id: string) => run(id, () => pipelines.reseed(id), 'Could not reseed pipeline')
|
|
43
|
+
const remove = (id: string) =>
|
|
44
|
+
run(id, () => pipelines.removePipeline(id), 'Could not delete pipeline')
|
|
45
|
+
|
|
46
|
+
/** Reseed every reseedable pipeline (outdated built-ins + invalid built-ins) in one go. */
|
|
47
|
+
async function reseedAll() {
|
|
48
|
+
const ids = [...invalid.value.filter((h) => h.pipeline.builtin), ...outdated.value].map(
|
|
49
|
+
(h) => h.pipeline.id,
|
|
50
|
+
)
|
|
51
|
+
for (const id of new Set(ids)) await reseed(id)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const reseedableCount = computed(
|
|
55
|
+
() =>
|
|
56
|
+
new Set([
|
|
57
|
+
...invalid.value.filter((h) => h.pipeline.builtin).map((h) => h.pipeline.id),
|
|
58
|
+
...outdated.value.map((h) => h.pipeline.id),
|
|
59
|
+
]).size,
|
|
60
|
+
)
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<UModal v-model:open="open" title="Pipeline health" :ui="{ content: 'max-w-2xl' }">
|
|
65
|
+
<template #body>
|
|
66
|
+
<div v-if="!hasIssues" class="py-6 text-center text-sm text-slate-400">
|
|
67
|
+
<UIcon name="i-lucide-check-circle-2" class="mx-auto mb-2 h-8 w-8 text-emerald-400" />
|
|
68
|
+
All pipelines are valid and up to date.
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div v-else class="space-y-5">
|
|
72
|
+
<!-- Invalid: unknown agent kinds or a broken shape. -->
|
|
73
|
+
<section v-if="invalid.length" class="space-y-2">
|
|
74
|
+
<div class="flex items-center gap-2">
|
|
75
|
+
<UIcon name="i-lucide-triangle-alert" class="h-4 w-4 text-rose-400" />
|
|
76
|
+
<h3 class="text-sm font-semibold text-slate-200">Invalid pipelines</h3>
|
|
77
|
+
</div>
|
|
78
|
+
<p class="text-[11px] text-slate-500">
|
|
79
|
+
These reference a missing agent or are misconfigured, so they would fail (or misrun) at
|
|
80
|
+
start. Delete a custom pipeline, or reseed a built-in to restore its catalog definition.
|
|
81
|
+
</p>
|
|
82
|
+
<ul class="space-y-2">
|
|
83
|
+
<li
|
|
84
|
+
v-for="h in invalid"
|
|
85
|
+
:key="h.pipeline.id"
|
|
86
|
+
class="rounded-lg border border-slate-800 bg-slate-900/40 p-3"
|
|
87
|
+
>
|
|
88
|
+
<div class="flex items-start justify-between gap-3">
|
|
89
|
+
<div class="min-w-0">
|
|
90
|
+
<div class="flex items-center gap-2">
|
|
91
|
+
<span class="truncate text-sm font-medium text-slate-100">
|
|
92
|
+
{{ h.pipeline.name }}
|
|
93
|
+
</span>
|
|
94
|
+
<UBadge v-if="h.pipeline.builtin" color="neutral" variant="subtle" size="xs">
|
|
95
|
+
built-in
|
|
96
|
+
</UBadge>
|
|
97
|
+
</div>
|
|
98
|
+
<ul class="mt-1 space-y-0.5">
|
|
99
|
+
<li
|
|
100
|
+
v-for="(p, i) in h.problems"
|
|
101
|
+
:key="i"
|
|
102
|
+
class="text-[11px]"
|
|
103
|
+
:class="p.type === 'outdated' ? 'text-amber-400/80' : 'text-rose-400/90'"
|
|
104
|
+
>
|
|
105
|
+
{{ p.message }}
|
|
106
|
+
</li>
|
|
107
|
+
</ul>
|
|
108
|
+
</div>
|
|
109
|
+
<UButton
|
|
110
|
+
v-if="h.pipeline.builtin"
|
|
111
|
+
size="xs"
|
|
112
|
+
color="primary"
|
|
113
|
+
variant="subtle"
|
|
114
|
+
icon="i-lucide-rotate-ccw"
|
|
115
|
+
:loading="isBusy(h.pipeline.id)"
|
|
116
|
+
:disabled="anyBusy"
|
|
117
|
+
@click="reseed(h.pipeline.id)"
|
|
118
|
+
>
|
|
119
|
+
Reseed
|
|
120
|
+
</UButton>
|
|
121
|
+
<UButton
|
|
122
|
+
v-else
|
|
123
|
+
size="xs"
|
|
124
|
+
color="error"
|
|
125
|
+
variant="subtle"
|
|
126
|
+
icon="i-lucide-trash-2"
|
|
127
|
+
:loading="isBusy(h.pipeline.id)"
|
|
128
|
+
:disabled="anyBusy"
|
|
129
|
+
@click="remove(h.pipeline.id)"
|
|
130
|
+
>
|
|
131
|
+
Delete
|
|
132
|
+
</UButton>
|
|
133
|
+
</div>
|
|
134
|
+
</li>
|
|
135
|
+
</ul>
|
|
136
|
+
</section>
|
|
137
|
+
|
|
138
|
+
<!-- Outdated built-ins: a newer catalog version is available. -->
|
|
139
|
+
<section v-if="outdated.length" class="space-y-2">
|
|
140
|
+
<div class="flex items-center gap-2">
|
|
141
|
+
<UIcon name="i-lucide-arrow-up-circle" class="h-4 w-4 text-amber-400" />
|
|
142
|
+
<h3 class="text-sm font-semibold text-slate-200">Updates available</h3>
|
|
143
|
+
</div>
|
|
144
|
+
<p class="text-[11px] text-slate-500">
|
|
145
|
+
A newer version of these built-in pipelines has shipped. Reseed to adopt it (your labels
|
|
146
|
+
and archive state are kept).
|
|
147
|
+
</p>
|
|
148
|
+
<ul class="space-y-2">
|
|
149
|
+
<li
|
|
150
|
+
v-for="h in outdated"
|
|
151
|
+
:key="h.pipeline.id"
|
|
152
|
+
class="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/40 p-3"
|
|
153
|
+
>
|
|
154
|
+
<div class="min-w-0">
|
|
155
|
+
<span class="truncate text-sm font-medium text-slate-100">{{
|
|
156
|
+
h.pipeline.name
|
|
157
|
+
}}</span>
|
|
158
|
+
<p class="text-[11px] text-amber-400/80">{{ h.problems[0]?.message }}</p>
|
|
159
|
+
</div>
|
|
160
|
+
<UButton
|
|
161
|
+
size="xs"
|
|
162
|
+
color="primary"
|
|
163
|
+
variant="subtle"
|
|
164
|
+
icon="i-lucide-rotate-ccw"
|
|
165
|
+
:loading="isBusy(h.pipeline.id)"
|
|
166
|
+
:disabled="anyBusy"
|
|
167
|
+
@click="reseed(h.pipeline.id)"
|
|
168
|
+
>
|
|
169
|
+
Reseed
|
|
170
|
+
</UButton>
|
|
171
|
+
</li>
|
|
172
|
+
</ul>
|
|
173
|
+
</section>
|
|
174
|
+
</div>
|
|
175
|
+
</template>
|
|
176
|
+
|
|
177
|
+
<template #footer>
|
|
178
|
+
<div class="flex w-full items-center justify-between gap-2">
|
|
179
|
+
<UButton
|
|
180
|
+
v-if="reseedableCount > 1"
|
|
181
|
+
color="primary"
|
|
182
|
+
variant="ghost"
|
|
183
|
+
icon="i-lucide-rotate-ccw"
|
|
184
|
+
:loading="anyBusy"
|
|
185
|
+
@click="reseedAll"
|
|
186
|
+
>
|
|
187
|
+
Reseed all ({{ reseedableCount }})
|
|
188
|
+
</UButton>
|
|
189
|
+
<span v-else />
|
|
190
|
+
<UButton
|
|
191
|
+
color="neutral"
|
|
192
|
+
variant="ghost"
|
|
193
|
+
:disabled="anyBusy"
|
|
194
|
+
@click="ui.closePipelineHealth()"
|
|
195
|
+
>
|
|
196
|
+
{{ hasIssues ? 'Dismiss' : 'Done' }}
|
|
197
|
+
</UButton>
|
|
198
|
+
</div>
|
|
199
|
+
</template>
|
|
200
|
+
</UModal>
|
|
201
|
+
</template>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
acceptInvitationContract,
|
|
3
3
|
authConfigContract,
|
|
4
|
+
forgotPasswordContract,
|
|
4
5
|
logoutContract,
|
|
5
6
|
meContract,
|
|
6
7
|
passwordLoginContract,
|
|
7
8
|
peekInvitationContract,
|
|
9
|
+
resetPasswordContract,
|
|
8
10
|
signupContract,
|
|
9
11
|
} from '@cat-factory/contracts'
|
|
10
12
|
import type { ApiContext } from './context'
|
|
@@ -25,6 +27,15 @@ export function authApi({ http, send, ws }: ApiContext) {
|
|
|
25
27
|
passwordLogin: (body: { email: string; password: string }) =>
|
|
26
28
|
send(passwordLoginContract, { pathPrefix: '/auth', body }),
|
|
27
29
|
|
|
30
|
+
// Request a reset link. Always succeeds (204) regardless of whether the email is
|
|
31
|
+
// registered, so the response can't be used to enumerate accounts.
|
|
32
|
+
forgotPassword: (body: { email: string }) =>
|
|
33
|
+
send(forgotPasswordContract, { pathPrefix: '/auth', body }),
|
|
34
|
+
|
|
35
|
+
// Redeem a reset token + set a new password (throws 400 on an invalid/expired token).
|
|
36
|
+
resetPassword: (body: { token: string; password: string }) =>
|
|
37
|
+
send(resetPasswordContract, { pathPrefix: '/auth', body }),
|
|
38
|
+
|
|
28
39
|
peekInvite: (token: string) =>
|
|
29
40
|
send(peekInvitationContract, { pathPrefix: '/auth', pathParams: { token } }),
|
|
30
41
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
organizePipelineContract,
|
|
14
14
|
removeBlockContract,
|
|
15
15
|
reparentBlockContract,
|
|
16
|
+
reseedPipelineContract,
|
|
16
17
|
toggleDependencyContract,
|
|
17
18
|
updateBlockContract,
|
|
18
19
|
updatePipelineContract,
|
|
@@ -129,5 +130,10 @@ export function boardApi({ send, ws }: ApiContext) {
|
|
|
129
130
|
|
|
130
131
|
removePipeline: (workspaceId: string, pipelineId: string) =>
|
|
131
132
|
send(deletePipelineContract, { pathPrefix: ws(workspaceId), pathParams: { pipelineId } }),
|
|
133
|
+
|
|
134
|
+
// Restore a built-in pipeline to its current catalog definition (adopt an improved
|
|
135
|
+
// built-in, or repair a drifted/invalid one). Custom pipelines reject this.
|
|
136
|
+
reseedPipeline: (workspaceId: string, pipelineId: string) =>
|
|
137
|
+
send(reseedPipelineContract, { pathPrefix: ws(workspaceId), pathParams: { pipelineId } }),
|
|
132
138
|
}
|
|
133
139
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { Pipeline } from '~/types/domain'
|
|
3
|
+
import { isKnownAgentKind } from '~/utils/catalog'
|
|
4
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
5
|
+
import { usePipelineHealth } from '~/composables/usePipelineHealth'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Guards the startup pipeline-health advisory against the failure that bit the first cut: a
|
|
9
|
+
* legitimate built-in agent kind missing from the frontend catalog made `isKnownAgentKind`
|
|
10
|
+
* return false, so a stock seeded pipeline (`pl_tech_debt`, which uses `analysis` + `tracker`)
|
|
11
|
+
* was reported "invalid" in every workspace with a Reseed action that could never fix it.
|
|
12
|
+
*
|
|
13
|
+
* The kind lists below mirror the canonical built-ins in
|
|
14
|
+
* `backend/packages/kernel/src/domain/seed.ts`; keep them in step when a seed pipeline gains a
|
|
15
|
+
* new kind. The `every built-in seed kind is known` test then fails loudly if the catalog drifts.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
let nextId = 0
|
|
19
|
+
function builtin(agentKinds: string[], over: Partial<Pipeline> = {}): Pipeline {
|
|
20
|
+
return {
|
|
21
|
+
id: `pl_test_${nextId++}`,
|
|
22
|
+
name: 'Test',
|
|
23
|
+
agentKinds,
|
|
24
|
+
builtin: true,
|
|
25
|
+
version: 1,
|
|
26
|
+
...over,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Seed the store with pipelines + their current catalog versions, then scan. */
|
|
31
|
+
function scan(pipelines: Pipeline[], versions: Record<string, number> = {}) {
|
|
32
|
+
const store = usePipelinesStore()
|
|
33
|
+
const catalogVersions = {
|
|
34
|
+
...Object.fromEntries(pipelines.filter((p) => p.builtin).map((p) => [p.id, p.version ?? 0])),
|
|
35
|
+
...versions,
|
|
36
|
+
}
|
|
37
|
+
store.hydrate(pipelines, catalogVersions)
|
|
38
|
+
return usePipelineHealth()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Every agent kind any built-in catalog pipeline references (mirror of seed.ts). The advisory's
|
|
42
|
+
// validity oracle (`isKnownAgentKind`) must recognise all of them, or a stock pipeline is
|
|
43
|
+
// falsely flagged. `analysis`/`tracker` are the two that originally regressed.
|
|
44
|
+
const BUILTIN_SEED_KINDS = [
|
|
45
|
+
'requirements-review',
|
|
46
|
+
'spec-writer',
|
|
47
|
+
'architect',
|
|
48
|
+
'coder',
|
|
49
|
+
'reviewer',
|
|
50
|
+
'blueprints',
|
|
51
|
+
'mocker',
|
|
52
|
+
'tester',
|
|
53
|
+
'conflicts',
|
|
54
|
+
'ci',
|
|
55
|
+
'merger',
|
|
56
|
+
'integrator',
|
|
57
|
+
'documenter',
|
|
58
|
+
'analysis',
|
|
59
|
+
'tracker',
|
|
60
|
+
'human-test',
|
|
61
|
+
'human-review',
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
describe('isKnownAgentKind', () => {
|
|
65
|
+
it('recognises every agent kind used by the built-in seed catalog', () => {
|
|
66
|
+
const unknown = BUILTIN_SEED_KINDS.filter((k) => !isKnownAgentKind(k))
|
|
67
|
+
expect(unknown).toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('specifically recognises analysis + tracker (the kinds that regressed)', () => {
|
|
71
|
+
expect(isKnownAgentKind('analysis')).toBe(true)
|
|
72
|
+
expect(isKnownAgentKind('tracker')).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns false for a genuinely unknown kind', () => {
|
|
76
|
+
expect(isKnownAgentKind('totally-made-up-kind')).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('usePipelineHealth', () => {
|
|
81
|
+
it('does not flag the stock tech-debt built-in (analysis + tracker) as invalid', () => {
|
|
82
|
+
const techDebt = builtin(
|
|
83
|
+
[
|
|
84
|
+
'analysis',
|
|
85
|
+
'tracker',
|
|
86
|
+
'coder',
|
|
87
|
+
'reviewer',
|
|
88
|
+
'blueprints',
|
|
89
|
+
'tester',
|
|
90
|
+
'conflicts',
|
|
91
|
+
'ci',
|
|
92
|
+
'merger',
|
|
93
|
+
],
|
|
94
|
+
{ id: 'pl_tech_debt', name: 'Tech debt' },
|
|
95
|
+
)
|
|
96
|
+
const { hasIssues, invalid, outdated } = scan([techDebt])
|
|
97
|
+
expect(hasIssues.value).toBe(false)
|
|
98
|
+
expect(invalid.value).toHaveLength(0)
|
|
99
|
+
expect(outdated.value).toHaveLength(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('flags a pipeline that references an unknown agent kind', () => {
|
|
103
|
+
const broken = builtin(['coder', 'bogus-kind'])
|
|
104
|
+
const { invalid } = scan([broken])
|
|
105
|
+
expect(invalid.value).toHaveLength(1)
|
|
106
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'unknown-kind')).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('accepts a valid producer + companion chain', () => {
|
|
110
|
+
const { hasIssues } = scan([builtin(['coder', 'reviewer'])])
|
|
111
|
+
expect(hasIssues.value).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('flags a companion with no preceding producer it can review (shape)', () => {
|
|
115
|
+
const { invalid } = scan([builtin(['reviewer'])])
|
|
116
|
+
expect(invalid.value).toHaveLength(1)
|
|
117
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('flags an estimate-gated companion with no task-estimator before it (shape)', () => {
|
|
121
|
+
const gated = builtin(['coder', 'reviewer'], {
|
|
122
|
+
gating: [null, { enabled: true, minComplexity: 0.5, onMissingEstimate: 'run' }],
|
|
123
|
+
})
|
|
124
|
+
const { invalid } = scan([gated])
|
|
125
|
+
expect(invalid.value).toHaveLength(1)
|
|
126
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('reports a built-in whose catalog version moved ahead as outdated (not invalid)', () => {
|
|
130
|
+
const stale = builtin(['coder', 'reviewer'], { id: 'pl_stale', version: 1 })
|
|
131
|
+
const { invalid, outdated } = scan([stale], { pl_stale: 2 })
|
|
132
|
+
expect(invalid.value).toHaveLength(0)
|
|
133
|
+
expect(outdated.value).toHaveLength(1)
|
|
134
|
+
expect(outdated.value[0]!.problems[0]!.type).toBe('outdated')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('keeps an invalid + outdated built-in out of the outdated list (one fix, not two)', () => {
|
|
138
|
+
const both = builtin(['coder', 'bogus-kind'], { id: 'pl_both', version: 1 })
|
|
139
|
+
const { invalid, outdated } = scan([both], { pl_both: 2 })
|
|
140
|
+
expect(invalid.value).toHaveLength(1)
|
|
141
|
+
expect(outdated.value).toHaveLength(0)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import type { Pipeline } from '~/types/domain'
|
|
3
|
+
import type { StepGating } from '~/types/consensus'
|
|
4
|
+
import { COMPANION_FOR_PRODUCER, isKnownAgentKind, isProducerCompanion } from '~/utils/catalog'
|
|
5
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
6
|
+
|
|
7
|
+
/** Estimate-gating consults a `task-estimator` step (mirrors the backend constant). */
|
|
8
|
+
const TASK_ESTIMATOR_KIND = 'task-estimator'
|
|
9
|
+
|
|
10
|
+
export type PipelineProblemType = 'unknown-kind' | 'shape' | 'outdated'
|
|
11
|
+
|
|
12
|
+
export interface PipelineProblem {
|
|
13
|
+
type: PipelineProblemType
|
|
14
|
+
message: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PipelineHealth {
|
|
18
|
+
pipeline: Pipeline
|
|
19
|
+
problems: PipelineProblem[]
|
|
20
|
+
/** Structural / unknown-kind problems — delete (custom) or reseed (built-in) to fix. */
|
|
21
|
+
invalid: boolean
|
|
22
|
+
/** A built-in whose catalog definition is newer than the stored copy — reseed to update. */
|
|
23
|
+
outdated: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Producers a companion kind is allowed to review (inverse of {@link COMPANION_FOR_PRODUCER}). */
|
|
27
|
+
function companionTargets(companion: string): string[] {
|
|
28
|
+
return Object.entries(COMPANION_FOR_PRODUCER)
|
|
29
|
+
.filter(([, c]) => c === companion)
|
|
30
|
+
.map(([producer]) => producer)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isEnabledAt = (p: Pipeline, i: number) => p.enabled?.[i] !== false
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Client-side mirror of the backend `validatePipelineShape` (companion adjacency + estimate
|
|
37
|
+
* gating, over the ENABLED subset), collecting the first problem instead of throwing. Returns a
|
|
38
|
+
* human message, or null when the shape is valid. Kept in step with
|
|
39
|
+
* `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
|
|
40
|
+
*/
|
|
41
|
+
function shapeProblem(p: Pipeline): string | null {
|
|
42
|
+
const kinds = p.agentKinds
|
|
43
|
+
// No enabled steps ⇒ nothing would run.
|
|
44
|
+
if (kinds.length === 0 || !kinds.some((_, i) => isEnabledAt(p, i))) {
|
|
45
|
+
return 'No enabled steps — the pipeline has nothing to run.'
|
|
46
|
+
}
|
|
47
|
+
// Companion adjacency: an enabled companion's nearest preceding enabled step must be a
|
|
48
|
+
// producer it can review.
|
|
49
|
+
for (let i = 0; i < kinds.length; i++) {
|
|
50
|
+
const kind = kinds[i]
|
|
51
|
+
if (!kind || !isProducerCompanion(kind) || !isEnabledAt(p, i)) continue
|
|
52
|
+
const targets = companionTargets(kind)
|
|
53
|
+
let predecessor: string | undefined
|
|
54
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
55
|
+
if (isEnabledAt(p, j)) {
|
|
56
|
+
predecessor = kinds[j]
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (predecessor === undefined || !targets.includes(predecessor)) {
|
|
61
|
+
return `Companion '${kind}' must run immediately after an enabled step it can review (${targets.join(', ')}).`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Estimate gating: an enabled gated step must be a companion, set ≥1 threshold, and have an
|
|
65
|
+
// enabled task-estimator earlier in the chain.
|
|
66
|
+
const gating = p.gating
|
|
67
|
+
if (gating) {
|
|
68
|
+
for (let i = 0; i < kinds.length; i++) {
|
|
69
|
+
const g = gating[i] as StepGating | null | undefined
|
|
70
|
+
if (!g?.enabled || !isEnabledAt(p, i)) continue
|
|
71
|
+
const kind = kinds[i]
|
|
72
|
+
if (!kind || !isProducerCompanion(kind)) {
|
|
73
|
+
return `Step '${kind}' cannot be estimate-gated — only companion steps may be skipped on the estimate.`
|
|
74
|
+
}
|
|
75
|
+
if (g.minComplexity === undefined && g.minRisk === undefined && g.minImpact === undefined) {
|
|
76
|
+
return `Step '${kind}' is estimate-gated but sets no threshold (complexity / risk / impact).`
|
|
77
|
+
}
|
|
78
|
+
const hasEstimator = kinds
|
|
79
|
+
.slice(0, i)
|
|
80
|
+
.some((k, j) => k === TASK_ESTIMATOR_KIND && isEnabledAt(p, j))
|
|
81
|
+
if (!hasEstimator) {
|
|
82
|
+
return `Step '${kind}' is gated on the estimate but no enabled '${TASK_ESTIMATOR_KIND}' runs before it.`
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Detect pipelines in an unhealthy state for the startup advisory: those referencing an unknown
|
|
91
|
+
* agent kind or with an invalid shape (offer to delete a custom one / reseed a built-in), and
|
|
92
|
+
* built-ins whose seeded definition has moved ahead of the stored copy (offer to reseed). Reads
|
|
93
|
+
* the pipeline library + the snapshot's catalog versions from the pipelines store. Detection runs
|
|
94
|
+
* entirely client-side because the canonical agent-kind catalog lives here (`AGENT_BY_KIND` +
|
|
95
|
+
* `SYSTEM_AGENT_META` + registered custom kinds); the version comparison uses the catalog
|
|
96
|
+
* versions the snapshot ships.
|
|
97
|
+
*/
|
|
98
|
+
export function usePipelineHealth() {
|
|
99
|
+
const store = usePipelinesStore()
|
|
100
|
+
|
|
101
|
+
const health = computed<PipelineHealth[]>(() => {
|
|
102
|
+
const out: PipelineHealth[] = []
|
|
103
|
+
for (const pipeline of store.pipelines) {
|
|
104
|
+
const problems: PipelineProblem[] = []
|
|
105
|
+
|
|
106
|
+
const unknown = [...new Set(pipeline.agentKinds.filter((k) => !isKnownAgentKind(k)))]
|
|
107
|
+
if (unknown.length) {
|
|
108
|
+
problems.push({
|
|
109
|
+
type: 'unknown-kind',
|
|
110
|
+
message: `References unknown agent ${unknown.length > 1 ? 'kinds' : 'kind'}: ${unknown.join(', ')}.`,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const shape = shapeProblem(pipeline)
|
|
115
|
+
if (shape) problems.push({ type: 'shape', message: shape })
|
|
116
|
+
|
|
117
|
+
const catalogVersion = pipeline.builtin ? store.catalogVersions[pipeline.id] : undefined
|
|
118
|
+
const outdated = catalogVersion !== undefined && catalogVersion > (pipeline.version ?? 0)
|
|
119
|
+
if (outdated) {
|
|
120
|
+
problems.push({
|
|
121
|
+
type: 'outdated',
|
|
122
|
+
message: `A newer version of this built-in pipeline is available (v${pipeline.version ?? 0} → v${catalogVersion}).`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (problems.length) {
|
|
127
|
+
out.push({ pipeline, problems, invalid: unknown.length > 0 || shape !== null, outdated })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// An invalid built-in is reseeded (not deleted) and that also clears any "outdated" flag, so
|
|
134
|
+
// exclude it from the outdated list to avoid offering the same fix twice.
|
|
135
|
+
const invalid = computed(() => health.value.filter((h) => h.invalid))
|
|
136
|
+
const outdated = computed(() => health.value.filter((h) => h.outdated && !h.invalid))
|
|
137
|
+
const hasIssues = computed(() => health.value.length > 0)
|
|
138
|
+
|
|
139
|
+
return { health, invalid, outdated, hasIssues }
|
|
140
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -49,6 +49,11 @@ const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPa
|
|
|
49
49
|
const FragmentLibraryPanel = defineAsyncComponent(
|
|
50
50
|
() => import('~/components/fragments/FragmentLibraryPanel.vue'),
|
|
51
51
|
)
|
|
52
|
+
// Startup advisory for invalid / outdated pipelines — only mounted while open (auto-opened
|
|
53
|
+
// at most once per session by the watcher below), so it stays out of the initial bundle.
|
|
54
|
+
const PipelineHealthModal = defineAsyncComponent(
|
|
55
|
+
() => import('~/components/pipeline/PipelineHealthModal.vue'),
|
|
56
|
+
)
|
|
52
57
|
const IntegrationsHub = defineAsyncComponent(
|
|
53
58
|
() => import('~/components/layout/IntegrationsHub.vue'),
|
|
54
59
|
)
|
|
@@ -122,11 +127,25 @@ watch(
|
|
|
122
127
|
autoOpenedSetup.value = false
|
|
123
128
|
autoOpenedPreset.value = false
|
|
124
129
|
ui.resetAiOnboarding()
|
|
130
|
+
// A different board has its own pipeline library, so re-arm the once-per-session advisory.
|
|
131
|
+
ui.pipelineHealthSeen = false
|
|
125
132
|
}
|
|
126
133
|
},
|
|
127
134
|
{ immediate: true },
|
|
128
135
|
)
|
|
129
136
|
|
|
137
|
+
// Pipeline-health advisory: once a board is loaded, surface any invalid / outdated pipelines in
|
|
138
|
+
// a startup modal (auto-opened at most once per session per board — later opens are user-driven).
|
|
139
|
+
// Detection is reactive, so this fires as soon as the snapshot hydrates.
|
|
140
|
+
const { hasIssues: pipelineIssues } = usePipelineHealth()
|
|
141
|
+
watch(
|
|
142
|
+
() => [workspace.ready, pipelineIssues.value],
|
|
143
|
+
() => {
|
|
144
|
+
if (workspace.ready && pipelineIssues.value) ui.maybeOpenPipelineHealth()
|
|
145
|
+
},
|
|
146
|
+
{ immediate: true },
|
|
147
|
+
)
|
|
148
|
+
|
|
130
149
|
// Auto-open the right AI-onboarding dialog once per session: the no-source prompt takes
|
|
131
150
|
// precedence over the preset-mismatch prompt. Honour the per-session dismissed flags so a
|
|
132
151
|
// user who closed the banner isn't re-interrupted, and only auto-open once each (later opens
|
|
@@ -242,6 +261,7 @@ watch(
|
|
|
242
261
|
<GitHubPanel v-if="ui.githubOpen" />
|
|
243
262
|
<SlackPanel v-if="ui.slackOpen" />
|
|
244
263
|
<FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
|
|
264
|
+
<PipelineHealthModal v-if="ui.pipelineHealthOpen" />
|
|
245
265
|
<IntegrationsHub v-if="ui.integrationsOpen" />
|
|
246
266
|
<PersonalSetupModal v-if="ui.personalSetupOpen" />
|
|
247
267
|
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
package/app/stores/auth.ts
CHANGED
|
@@ -133,6 +133,16 @@ export const useAuthStore = defineStore(
|
|
|
133
133
|
applySession(await api.passwordLogin(body))
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Request a password-reset link by email (always resolves; never reveals existence). */
|
|
137
|
+
async function forgotPassword(email: string) {
|
|
138
|
+
await api.forgotPassword({ email })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Redeem a reset token and set a new password. Throws on an invalid/expired token. */
|
|
142
|
+
async function resetPassword(token: string, password: string) {
|
|
143
|
+
await api.resetPassword({ token, password })
|
|
144
|
+
}
|
|
145
|
+
|
|
136
146
|
/** Drop the local session (sessions are stateless server-side). */
|
|
137
147
|
function logout() {
|
|
138
148
|
api.logout().catch(() => {})
|
|
@@ -159,6 +169,8 @@ export const useAuthStore = defineStore(
|
|
|
159
169
|
loginWithGoogle,
|
|
160
170
|
signup,
|
|
161
171
|
passwordLogin,
|
|
172
|
+
forgotPassword,
|
|
173
|
+
resetPassword,
|
|
162
174
|
logout,
|
|
163
175
|
handleUnauthorized,
|
|
164
176
|
}
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -31,6 +31,12 @@ function defaultConsensusConfig(): ConsensusStepConfig {
|
|
|
31
31
|
export const usePipelinesStore = defineStore('pipelines', () => {
|
|
32
32
|
const api = useApi()
|
|
33
33
|
const pipelines = ref<Pipeline[]>([])
|
|
34
|
+
/**
|
|
35
|
+
* Current built-in catalog versions (`seedPipelines()`), keyed by pipeline id, from the
|
|
36
|
+
* workspace snapshot. A built-in whose stored `version` is below its catalog value here has
|
|
37
|
+
* a newer definition available (see `usePipelineHealth`).
|
|
38
|
+
*/
|
|
39
|
+
const catalogVersions = ref<Record<string, number>>({})
|
|
34
40
|
|
|
35
41
|
/** The chain currently being assembled in the builder. */
|
|
36
42
|
const draft = ref<AgentKind[]>([])
|
|
@@ -59,9 +65,10 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
59
65
|
/** The id of the pipeline being edited, or null when assembling a brand-new one. */
|
|
60
66
|
const editingId = ref<string | null>(null)
|
|
61
67
|
|
|
62
|
-
/** Replace the cached pipelines
|
|
63
|
-
function hydrate(next: Pipeline[]) {
|
|
68
|
+
/** Replace the cached pipelines (and the current built-in catalog versions) from a snapshot. */
|
|
69
|
+
function hydrate(next: Pipeline[], versions?: Record<string, number>) {
|
|
64
70
|
pipelines.value = next
|
|
71
|
+
if (versions) catalogVersions.value = versions
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
function getPipeline(id: string) {
|
|
@@ -300,6 +307,19 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
300
307
|
if (editingId.value === id) clearDraft()
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Reseed a built-in pipeline from the backend's current catalog definition: restores its
|
|
312
|
+
* canonical structure + version (adopting an update, or repairing a drifted/invalid copy)
|
|
313
|
+
* while preserving its labels/archive state. Replaces the pipeline in the cache.
|
|
314
|
+
*/
|
|
315
|
+
async function reseed(id: string): Promise<Pipeline> {
|
|
316
|
+
const updated = await api.reseedPipeline(useWorkspaceStore().requireId(), id)
|
|
317
|
+
const i = pipelines.value.findIndex((p) => p.id === updated.id)
|
|
318
|
+
if (i >= 0) pipelines.value[i] = updated
|
|
319
|
+
if (editingId.value === id) clearDraft()
|
|
320
|
+
return updated
|
|
321
|
+
}
|
|
322
|
+
|
|
303
323
|
/** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
|
|
304
324
|
async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
|
|
305
325
|
const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
|
|
@@ -314,6 +334,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
314
334
|
|
|
315
335
|
return {
|
|
316
336
|
pipelines,
|
|
337
|
+
catalogVersions,
|
|
317
338
|
draft,
|
|
318
339
|
draftGates,
|
|
319
340
|
draftEnabled,
|
|
@@ -344,6 +365,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
344
365
|
saveDraft,
|
|
345
366
|
clonePipeline,
|
|
346
367
|
removePipeline,
|
|
368
|
+
reseed,
|
|
347
369
|
organize,
|
|
348
370
|
archive,
|
|
349
371
|
unarchive,
|
package/app/stores/ui.ts
CHANGED
|
@@ -19,6 +19,11 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
19
19
|
const selectedBlockId = ref<string | null>(null)
|
|
20
20
|
const focusBlockId = ref<string | null>(null)
|
|
21
21
|
const builderOpen = ref(false)
|
|
22
|
+
// Pipeline-health startup advisory: lists invalid pipelines (delete / reseed) + built-ins
|
|
23
|
+
// with a newer catalog version (reseed). `pipelineHealthSeen` gates auto-open to once per
|
|
24
|
+
// session so it does not re-pop on every snapshot re-hydration.
|
|
25
|
+
const pipelineHealthOpen = ref(false)
|
|
26
|
+
const pipelineHealthSeen = ref(false)
|
|
22
27
|
const decisionContext = ref<{ instanceId: string; decisionId: string } | null>(null)
|
|
23
28
|
|
|
24
29
|
// Document-source integration modals, keyed by source. `documentImport` and
|
|
@@ -217,6 +222,22 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
217
222
|
builderOpen.value = true
|
|
218
223
|
}
|
|
219
224
|
|
|
225
|
+
/** Auto-open the pipeline-health advisory once per session (no-op after it's been shown). */
|
|
226
|
+
function maybeOpenPipelineHealth() {
|
|
227
|
+
if (pipelineHealthSeen.value) return
|
|
228
|
+
pipelineHealthSeen.value = true
|
|
229
|
+
pipelineHealthOpen.value = true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function openPipelineHealth() {
|
|
233
|
+
pipelineHealthSeen.value = true
|
|
234
|
+
pipelineHealthOpen.value = true
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function closePipelineHealth() {
|
|
238
|
+
pipelineHealthOpen.value = false
|
|
239
|
+
}
|
|
240
|
+
|
|
220
241
|
function openDecision(instanceId: string, decisionId: string) {
|
|
221
242
|
decisionContext.value = { instanceId, decisionId }
|
|
222
243
|
}
|
|
@@ -600,6 +621,8 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
600
621
|
selectedBlockId,
|
|
601
622
|
focusBlockId,
|
|
602
623
|
builderOpen,
|
|
624
|
+
pipelineHealthOpen,
|
|
625
|
+
pipelineHealthSeen,
|
|
603
626
|
decisionContext,
|
|
604
627
|
documentConnect,
|
|
605
628
|
documentImport,
|
|
@@ -651,6 +674,9 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
651
674
|
select,
|
|
652
675
|
focus,
|
|
653
676
|
openBuilder,
|
|
677
|
+
maybeOpenPipelineHealth,
|
|
678
|
+
openPipelineHealth,
|
|
679
|
+
closePipelineHealth,
|
|
654
680
|
openDecision,
|
|
655
681
|
closeDecision,
|
|
656
682
|
openApprovalDetail,
|
package/app/stores/workspace.ts
CHANGED
|
@@ -82,7 +82,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
82
82
|
if (i >= 0) workspaces.value[i] = snapshot.workspace
|
|
83
83
|
else workspaces.value.unshift(snapshot.workspace)
|
|
84
84
|
useBoardStore().hydrate(snapshot.blocks)
|
|
85
|
-
usePipelinesStore().hydrate(snapshot.pipelines)
|
|
85
|
+
usePipelinesStore().hydrate(snapshot.pipelines, snapshot.pipelineCatalogVersions)
|
|
86
86
|
useExecutionStore().hydrate(snapshot.executions)
|
|
87
87
|
useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [])
|
|
88
88
|
useNotificationsStore().hydrate(snapshot.notifications ?? [])
|
package/app/utils/catalog.ts
CHANGED
|
@@ -303,6 +303,28 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
|
|
|
303
303
|
color: '#22d3ee',
|
|
304
304
|
description: 'Maps the repository into the service → modules blueprint.',
|
|
305
305
|
},
|
|
306
|
+
// A read-only repository audit that emits a prioritized findings report. Not a palette
|
|
307
|
+
// archetype (it is only seeded into the recurring tech-debt pipeline), so it lives here
|
|
308
|
+
// for run-timeline / saved-pipeline display rather than in AGENT_ARCHETYPES.
|
|
309
|
+
analysis: {
|
|
310
|
+
kind: 'analysis',
|
|
311
|
+
label: 'Analyst',
|
|
312
|
+
icon: 'i-lucide-search-code',
|
|
313
|
+
color: '#818cf8',
|
|
314
|
+
description:
|
|
315
|
+
'Audits the repository read-only and emits a prioritized findings report (drives the tech-debt pipeline).',
|
|
316
|
+
},
|
|
317
|
+
// A one-shot engine step that files a tracker ticket (GitHub issue / Jira) from the
|
|
318
|
+
// preceding analysis before implementation. Runs no model itself; seeded only into the
|
|
319
|
+
// tech-debt pipeline, so it is a display-metadata system kind, not a palette archetype.
|
|
320
|
+
tracker: {
|
|
321
|
+
kind: 'tracker',
|
|
322
|
+
label: 'Issue Tracker',
|
|
323
|
+
icon: 'i-lucide-ticket',
|
|
324
|
+
color: '#fb923c',
|
|
325
|
+
description:
|
|
326
|
+
'Files a tracker ticket (GitHub issue / Jira) from the analysis before work starts.',
|
|
327
|
+
},
|
|
306
328
|
conflicts: {
|
|
307
329
|
kind: 'conflicts',
|
|
308
330
|
label: 'Conflicts Gate',
|
|
@@ -447,6 +469,18 @@ export function agentKindMeta(kind: string): AgentArchetype {
|
|
|
447
469
|
)
|
|
448
470
|
}
|
|
449
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Whether an agent kind is actually known to this build — a palette archetype or companion
|
|
474
|
+
* ({@link AGENT_BY_KIND}, which deployment custom kinds are merged into via
|
|
475
|
+
* `useAgentsStore().registerCustomKinds`), or an engine system/gate kind
|
|
476
|
+
* ({@link SYSTEM_AGENT_META}). Unlike {@link agentKindMeta} (which always returns a usable
|
|
477
|
+
* fallback so renderers never crash), this returns `false` for an unknown kind — used to flag
|
|
478
|
+
* a pipeline that references a nonexistent agent. Call AFTER custom kinds are registered.
|
|
479
|
+
*/
|
|
480
|
+
export function isKnownAgentKind(kind: string): boolean {
|
|
481
|
+
return kind in AGENT_BY_KIND || kind in SYSTEM_AGENT_META
|
|
482
|
+
}
|
|
483
|
+
|
|
450
484
|
type BlockTypeMeta = { label: string; icon: string; accent: string }
|
|
451
485
|
|
|
452
486
|
/** Visual metadata for each architecture block type. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"pinia-plugin-persistedstate": "^4.7.1",
|
|
33
33
|
"vue": "^3.5.38",
|
|
34
34
|
"wretch": "^3.0.9",
|
|
35
|
-
"@cat-factory/contracts": "0.
|
|
35
|
+
"@cat-factory/contracts": "0.38.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@toad-contracts/testing": "0.3.1",
|