@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.
@@ -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; flip to signup when invited.
16
- const mode = ref<'login' | 'signup'>(invite.value ? 'signup' : 'login')
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
- {{ invite ? 'Accept your invitation to continue.' : 'Sign in to continue.' }}
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 v-if="showOAuthDivider" class="my-4 flex items-center gap-3 text-xs text-slate-500">
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 v-if="auth.providers.password" class="space-y-3" @submit.prevent="submitPassword">
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 type="button" class="text-indigo-400 hover:underline" @click="mode = 'signup'">
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="mode = 'login'">
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
+ }
@@ -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" />
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ import ResetPasswordScreen from '~/components/auth/ResetPasswordScreen.vue'
3
+ </script>
4
+
5
+ <template>
6
+ <ResetPasswordScreen />
7
+ </template>
@@ -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
  }
@@ -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 with a server snapshot. */
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,
@@ -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 ?? [])
@@ -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.37.3",
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.36.0"
35
+ "@cat-factory/contracts": "0.38.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@toad-contracts/testing": "0.3.1",