@cat-factory/app 0.190.1 → 0.192.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/layout/InfraSetupBanner.vue +139 -35
- package/app/components/layout/NotificationsInbox.vue +5 -0
- package/app/components/prReview/PrReviewWindow.vue +55 -1
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/api/prReview.ts +10 -0
- package/app/composables/useWorkspaceStream.ts +6 -0
- package/app/stores/execution.spec.ts +87 -0
- package/app/stores/execution.ts +39 -0
- package/app/stores/followUps.ts +14 -8
- package/app/stores/forkDecision.ts +35 -15
- package/app/stores/judge.ts +25 -13
- package/app/stores/prReview.ts +63 -24
- package/app/stores/ui/modals.ts +31 -8
- package/app/stores/workspace/infraSetup.ts +77 -0
- package/app/stores/workspace.spec.ts +129 -0
- package/app/stores/workspace.ts +12 -8
- package/app/utils/infraSetup.ts +29 -0
- package/i18n/locales/de.json +13 -0
- package/i18n/locales/en.json +13 -0
- package/i18n/locales/es.json +13 -0
- package/i18n/locales/fr.json +13 -0
- package/i18n/locales/he.json +13 -0
- package/i18n/locales/it.json +13 -0
- package/i18n/locales/ja.json +13 -0
- package/i18n/locales/pl.json +13 -0
- package/i18n/locales/tr.json +13 -0
- package/i18n/locales/uk.json +13 -0
- package/package.json +2 -2
|
@@ -22,17 +22,36 @@
|
|
|
22
22
|
// account that also has no storage — an accepted trade-off (the setting stays reachable from
|
|
23
23
|
// account settings, and the SESSION dismissal re-nags on the next load regardless).
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
25
|
+
// Two KINDS of card share this surface, and the difference drives the dismissal fork:
|
|
26
|
+
// - a setup gap (`not_defined`) is a stable operator decision, so both dismissals are offered;
|
|
27
|
+
// - an OUTAGE (`unreachable` — configured, but the reachability watcher's live probe can't reach
|
|
28
|
+
// it) is a health state, so ONLY the session dismissal is offered. A permanent "don't notify me
|
|
29
|
+
// again" on a transient failure would let one click silence every future outage, and the outage
|
|
30
|
+
// that matters is always the next one. `isInfraSetupHealthStatus` (contracts) is the single
|
|
31
|
+
// definition both this component and the store's re-nag logic key off.
|
|
32
|
+
//
|
|
33
|
+
// BOTH dismissals are keyed by the CLAIM, never by the area alone, because the two cards say
|
|
34
|
+
// different things about the same area: silencing "you haven't configured this" must not also
|
|
35
|
+
// silence the outage card raised after the operator configures it and the provider then dies.
|
|
36
|
+
//
|
|
37
|
+
// Freshness note: a setup gap clears on the next board load after the operator configures the area
|
|
38
|
+
// via the deep-link, not the instant the config panel saves — the projection is recomputed on
|
|
39
|
+
// snapshot (re)load. An OUTAGE is different: it arrives and clears live, pushed as an `infraSetup`
|
|
40
|
+
// event by the watcher and applied by `workspace.patchInfraSetup`.
|
|
28
41
|
import { useLocalStorage } from '@vueuse/core'
|
|
29
42
|
import { computed } from 'vue'
|
|
30
43
|
// The localStorage key holding the permanent per-user dismissals lives in `@cat-factory/contracts`
|
|
31
44
|
// (a dependency-free package the SPA and the e2e suite both import), so the key + shape can't drift
|
|
32
45
|
// between this component and the e2e seed in `backend/internal/e2e/tests/helpers.ts` (`pinWorkspace`).
|
|
33
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
INFRA_SETUP_DISMISSED_STORAGE_KEY,
|
|
48
|
+
type InfraSetupProbedArea,
|
|
49
|
+
isInfraSetupHealthStatus,
|
|
50
|
+
isInfraSetupProbedArea,
|
|
51
|
+
} from '@cat-factory/contracts'
|
|
34
52
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
35
|
-
import type { InfraSetupArea } from '~/types/domain'
|
|
53
|
+
import type { InfraSetupArea, InfraSetupStatus } from '~/types/domain'
|
|
54
|
+
import { infraSetupDismissalKey, type InfraSetupCardKind } from '~/utils/infraSetup'
|
|
36
55
|
|
|
37
56
|
const { t } = useI18n()
|
|
38
57
|
const ui = useUiStore()
|
|
@@ -75,6 +94,18 @@ const AREA_META: Record<
|
|
|
75
94
|
},
|
|
76
95
|
}
|
|
77
96
|
|
|
97
|
+
// Outage titles are keyed over the PROBED areas only, not over every area: `binaryStorage` has no
|
|
98
|
+
// reachability probe, so an entry for it would be copy that ships in every locale and can never
|
|
99
|
+
// render. Each probed area carries its OWN title rather than interpolating the area name into a
|
|
100
|
+
// shared one — a predicate adjective ("is unreachable") agrees with its subject's gender in most of
|
|
101
|
+
// the locales we ship, so `{area} is unreachable` cannot be translated correctly as one string. The
|
|
102
|
+
// outage BODY and action ARE shared, because neither refers back to the area; each locale's body
|
|
103
|
+
// opens with its own fixed subject noun for exactly that reason.
|
|
104
|
+
const UNREACHABLE_TITLE_KEYS: Record<InfraSetupProbedArea, string> = {
|
|
105
|
+
agentExecutor: 'layout.infraSetupBanner.agentExecutor.unreachableTitle',
|
|
106
|
+
ephemeralEnvironments: 'layout.infraSetupBanner.ephemeralEnvironments.unreachableTitle',
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
// Permanent, per-user dismissals: one shared localStorage record keyed BY user id (so it's
|
|
79
110
|
// scoped to the signed-in user and doesn't leak across accounts on a shared browser). No
|
|
80
111
|
// signed-in user (local/auth-off single-user mode) ⇒ the `local` bucket.
|
|
@@ -94,30 +125,64 @@ function dismissPermanently(area: InfraSetupArea) {
|
|
|
94
125
|
}
|
|
95
126
|
}
|
|
96
127
|
|
|
97
|
-
|
|
128
|
+
/** One rendered card: the area plus which CLAIM it is making about it. */
|
|
129
|
+
interface AreaCard {
|
|
130
|
+
area: InfraSetupArea
|
|
131
|
+
/** `outage` for a live-health status — drives the copy, the severity styling and the dismissals. */
|
|
132
|
+
kind: InfraSetupCardKind
|
|
133
|
+
/** The failing probe's reason, when this session saw the transition that raised the card. */
|
|
134
|
+
detail?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const visible = computed<AreaCard[]>(() => {
|
|
98
138
|
const status = workspace.infraSetup
|
|
99
139
|
if (!status) return []
|
|
100
|
-
return AREAS.filter(
|
|
101
|
-
(area)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
140
|
+
return AREAS.filter((area) => {
|
|
141
|
+
const kind = cardKind(status[area])
|
|
142
|
+
if (!kind) return false
|
|
143
|
+
// Both dismissals are keyed by the CLAIM, not by the area: silencing "you haven't configured
|
|
144
|
+
// this" must not also silence "you configured it and it is now down". The permanent dismissal
|
|
145
|
+
// only ever covers a setup gap (`dismissPermanently` is offered nowhere else).
|
|
146
|
+
if (ui.infraSetupSessionDismissed.includes(infraSetupDismissalKey(area, kind))) return false
|
|
147
|
+
return !(kind === 'setup' && dismissedForUser.value.includes(area))
|
|
148
|
+
}).map((area) => ({
|
|
149
|
+
area,
|
|
150
|
+
kind: cardKind(status[area])!,
|
|
151
|
+
...(workspace.infraSetupDetails[area] ? { detail: workspace.infraSetupDetails[area] } : {}),
|
|
152
|
+
}))
|
|
106
153
|
})
|
|
107
154
|
|
|
108
|
-
|
|
109
|
-
function
|
|
155
|
+
/** Which card an area's status raises, or null when it raises none (`configured`/`not_applicable`). */
|
|
156
|
+
function cardKind(status: InfraSetupStatus): InfraSetupCardKind | null {
|
|
157
|
+
if (isInfraSetupHealthStatus(status)) return 'outage'
|
|
158
|
+
return status === 'not_defined' ? 'setup' : null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The card's title key: the per-area outage title for an outage, else the setup-gap title. */
|
|
162
|
+
function titleKey(card: AreaCard): string {
|
|
163
|
+
return card.kind === 'outage' && isInfraSetupProbedArea(card.area)
|
|
164
|
+
? UNREACHABLE_TITLE_KEYS[card.area]
|
|
165
|
+
: AREA_META[card.area].titleKey
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close. An
|
|
170
|
+
* outage offers the session option ONLY — see the fork note at the top of this file.
|
|
171
|
+
*/
|
|
172
|
+
function dismissMenu(card: AreaCard): DropdownMenuItem[][] {
|
|
173
|
+
const session = {
|
|
174
|
+
label: t('layout.infraSetupBanner.dismiss.session'),
|
|
175
|
+
icon: 'i-lucide-clock',
|
|
176
|
+
onSelect: () => ui.dismissInfraSetupForSession(card.area, card.kind),
|
|
177
|
+
}
|
|
178
|
+
if (card.kind === 'outage') return [[session]]
|
|
110
179
|
return [
|
|
111
180
|
[
|
|
112
|
-
|
|
113
|
-
label: t('layout.infraSetupBanner.dismiss.session'),
|
|
114
|
-
icon: 'i-lucide-clock',
|
|
115
|
-
onSelect: () => ui.dismissInfraSetupForSession(area),
|
|
116
|
-
},
|
|
181
|
+
session,
|
|
117
182
|
{
|
|
118
183
|
label: t('layout.infraSetupBanner.dismiss.permanent'),
|
|
119
184
|
icon: 'i-lucide-bell-off',
|
|
120
|
-
onSelect: () => dismissPermanently(area),
|
|
185
|
+
onSelect: () => dismissPermanently(card.area),
|
|
121
186
|
},
|
|
122
187
|
],
|
|
123
188
|
]
|
|
@@ -135,42 +200,81 @@ function dismissMenu(area: InfraSetupArea): DropdownMenuItem[][] {
|
|
|
135
200
|
role="status"
|
|
136
201
|
aria-live="polite"
|
|
137
202
|
>
|
|
203
|
+
<!-- An OUTAGE reads red, a setup gap amber: one is something breaking now, the other is
|
|
204
|
+
something never switched on, and a reader has to be able to tell at a glance. -->
|
|
138
205
|
<div
|
|
139
|
-
v-for="
|
|
140
|
-
:key="area"
|
|
141
|
-
class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2
|
|
142
|
-
:
|
|
206
|
+
v-for="card in visible"
|
|
207
|
+
:key="card.area"
|
|
208
|
+
class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2 p-5 shadow-2xl backdrop-blur"
|
|
209
|
+
:class="
|
|
210
|
+
card.kind === 'outage'
|
|
211
|
+
? 'border-red-500/70 bg-red-950/95'
|
|
212
|
+
: 'border-amber-500/70 bg-amber-950/95'
|
|
213
|
+
"
|
|
214
|
+
:data-testid="`infra-setup-banner-${card.area}`"
|
|
215
|
+
:data-infra-status="card.kind === 'outage' ? 'unreachable' : 'not_defined'"
|
|
143
216
|
>
|
|
144
217
|
<div class="flex items-start gap-4">
|
|
145
|
-
<UIcon
|
|
218
|
+
<UIcon
|
|
219
|
+
:name="card.kind === 'outage' ? 'i-lucide-plug-zap' : AREA_META[card.area].icon"
|
|
220
|
+
class="mt-0.5 h-9 w-9 shrink-0"
|
|
221
|
+
:class="card.kind === 'outage' ? 'text-red-400' : 'text-amber-400'"
|
|
222
|
+
/>
|
|
146
223
|
<div class="min-w-0 flex-1">
|
|
147
224
|
<div class="flex items-start justify-between gap-3">
|
|
148
|
-
<h2
|
|
149
|
-
|
|
225
|
+
<h2
|
|
226
|
+
class="text-lg font-semibold"
|
|
227
|
+
:class="card.kind === 'outage' ? 'text-red-100' : 'text-amber-100'"
|
|
228
|
+
>
|
|
229
|
+
{{ t(titleKey(card)) }}
|
|
150
230
|
</h2>
|
|
151
|
-
<UDropdownMenu :items="dismissMenu(
|
|
231
|
+
<UDropdownMenu :items="dismissMenu(card)" :content="{ align: 'end' }">
|
|
152
232
|
<UButton
|
|
153
233
|
color="neutral"
|
|
154
234
|
variant="ghost"
|
|
155
235
|
size="xs"
|
|
156
236
|
icon="i-lucide-x"
|
|
157
237
|
:aria-label="t('common.close')"
|
|
158
|
-
:data-testid="`infra-setup-dismiss-${area}`"
|
|
238
|
+
:data-testid="`infra-setup-dismiss-${card.area}`"
|
|
159
239
|
/>
|
|
160
240
|
</UDropdownMenu>
|
|
161
241
|
</div>
|
|
162
|
-
<p
|
|
163
|
-
|
|
242
|
+
<p
|
|
243
|
+
class="mt-1 text-sm"
|
|
244
|
+
:class="card.kind === 'outage' ? 'text-red-200/90' : 'text-amber-200/90'"
|
|
245
|
+
>
|
|
246
|
+
{{
|
|
247
|
+
card.kind === 'outage'
|
|
248
|
+
? t('layout.infraSetupBanner.unreachable.body')
|
|
249
|
+
: t(AREA_META[card.area].bodyKey)
|
|
250
|
+
}}
|
|
251
|
+
</p>
|
|
252
|
+
<!-- The failing probe's OWN reason, when this session saw the transition that raised the
|
|
253
|
+
card: a refused connection, a rejected token and a timeout need different fixes, and
|
|
254
|
+
the generic body cannot tell them apart. Absent after a reload (it rides the live
|
|
255
|
+
event, never the deduped notification), so it is an addition to the copy above and
|
|
256
|
+
never the only thing that explains the card. -->
|
|
257
|
+
<p
|
|
258
|
+
v-if="card.detail"
|
|
259
|
+
class="mt-2 truncate font-mono text-xs text-red-300/80"
|
|
260
|
+
:title="card.detail"
|
|
261
|
+
:data-testid="`infra-setup-detail-${card.area}`"
|
|
262
|
+
>
|
|
263
|
+
{{ t('layout.infraSetupBanner.unreachable.reason', { detail: card.detail }) }}
|
|
164
264
|
</p>
|
|
165
265
|
<div class="mt-4">
|
|
166
266
|
<UButton
|
|
167
|
-
color="warning"
|
|
267
|
+
:color="card.kind === 'outage' ? 'error' : 'warning'"
|
|
168
268
|
variant="solid"
|
|
169
269
|
icon="i-lucide-settings"
|
|
170
|
-
:data-testid="`infra-setup-configure-${area}`"
|
|
171
|
-
@click="AREA_META[area].onConfigure()"
|
|
270
|
+
:data-testid="`infra-setup-configure-${card.area}`"
|
|
271
|
+
@click="AREA_META[card.area].onConfigure()"
|
|
172
272
|
>
|
|
173
|
-
{{
|
|
273
|
+
{{
|
|
274
|
+
card.kind === 'outage'
|
|
275
|
+
? t('layout.infraSetupBanner.unreachable.action')
|
|
276
|
+
: t(AREA_META[card.area].actionKey)
|
|
277
|
+
}}
|
|
174
278
|
</UButton>
|
|
175
279
|
</div>
|
|
176
280
|
</div>
|
|
@@ -88,6 +88,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
|
|
|
88
88
|
// sealed). Not block-scoped; "act" drops the listed stale ciphertexts so they can be re-entered
|
|
89
89
|
// (or restore the previous key to recover them instead).
|
|
90
90
|
key_drift: { icon: 'i-lucide-key-round', color: 'error' },
|
|
91
|
+
// A configured infrastructure connection stopped answering its live probe. Not block-scoped and
|
|
92
|
+
// nothing to act on from here — the fix is on the provider's side, and the card clears itself when
|
|
93
|
+
// the reachability watcher sees it answer again; "act" just marks it read.
|
|
94
|
+
infra_unreachable: { icon: 'i-lucide-plug-zap', color: 'error' },
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
// Per-type primary-action label. An exhaustive Record keyed off the notification
|
|
@@ -114,6 +118,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
|
|
|
114
118
|
platform_health: 'layout.notifications.action.platform_health',
|
|
115
119
|
budget_paused: 'layout.notifications.action.budget_paused',
|
|
116
120
|
key_drift: 'layout.notifications.action.key_drift',
|
|
121
|
+
infra_unreachable: 'layout.notifications.action.infra_unreachable',
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
/** The localized primary-action label for a notification (te()-guarded against a
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// findings to a Fixer that commits fixes onto the PR branch), `Post` (publish them as inline PR
|
|
8
8
|
// review comments), or `Finish` (just record the curated selection). Fix/Post act on the
|
|
9
9
|
// selection, so they require at least one selected finding.
|
|
10
|
+
//
|
|
11
|
+
// While the review is still RUNNING it also offers `Resume`, which re-dispatches a review that
|
|
12
|
+
// appears stuck for only the slices that never reported (see `canResume` for why it is always
|
|
13
|
+
// offered rather than gated on an activity heuristic).
|
|
10
14
|
import { computed, ref, watch } from 'vue'
|
|
11
15
|
import { useResultView } from '~/composables/useResultView'
|
|
12
16
|
import { useExecutionStore } from '~/stores/execution'
|
|
@@ -208,6 +212,25 @@ async function onResolve(action: PrReviewResolution): Promise<void> {
|
|
|
208
212
|
await prReview.resolve(id, activeSelectedIds.value, action).catch(() => {})
|
|
209
213
|
}
|
|
210
214
|
|
|
215
|
+
/**
|
|
216
|
+
* RESUME a review that appears stuck. Offered throughout the `reviewing` phase — including the
|
|
217
|
+
* neutral "planning" sub-state, since a wedge is just as possible before a plan is reported as
|
|
218
|
+
* after — because the whole complaint this answers is that a stuck review had no visible
|
|
219
|
+
* affordance at all. Deliberately NOT hidden behind a staleness heuristic: `lastActivityAt` freezes
|
|
220
|
+
* on a long silent turn (a single completion emits no tool call and grows no subagent transcript),
|
|
221
|
+
* so the platform cannot tell a wedged review from a quiet-but-working one, and hiding the control
|
|
222
|
+
* until it thinks it can would put it out of reach in exactly the case that motivated it.
|
|
223
|
+
*/
|
|
224
|
+
const canResume = computed(
|
|
225
|
+
() => status.value === 'reviewing' && !prReview.resuming && access.canExecuteRuns.value,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async function onResume(): Promise<void> {
|
|
229
|
+
const id = instanceId.value
|
|
230
|
+
if (!id || !canResume.value) return
|
|
231
|
+
await prReview.resume(id).catch(() => {})
|
|
232
|
+
}
|
|
233
|
+
|
|
211
234
|
// Per-finding CHALLENGE: the open finding's id (its inline concern box is showing) + the drafted
|
|
212
235
|
// concern text. Dispatching moves the whole review to `challenging` until the verdict lands.
|
|
213
236
|
const challengeForId = ref<string | null>(null)
|
|
@@ -273,7 +296,7 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
273
296
|
<div
|
|
274
297
|
v-if="planning"
|
|
275
298
|
data-testid="pr-review-planning"
|
|
276
|
-
class="flex
|
|
299
|
+
class="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
|
|
277
300
|
>
|
|
278
301
|
<UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
|
|
279
302
|
<p class="text-sm text-slate-200">{{ t('prReview.reviewing.planning.title') }}</p>
|
|
@@ -395,6 +418,37 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
395
418
|
</ul>
|
|
396
419
|
</template>
|
|
397
420
|
</div>
|
|
421
|
+
|
|
422
|
+
<!-- Nudge a review that looks stuck. Present in BOTH reviewing sub-states, and never
|
|
423
|
+
gated on a staleness guess: the heartbeat freezes on a long silent turn, so nothing
|
|
424
|
+
here can tell wedged from quiet-but-working (see `canResume`). Re-reviews only the
|
|
425
|
+
slices that never reported; the finished ones are re-aggregated from their captured
|
|
426
|
+
reports. -->
|
|
427
|
+
<div class="mt-4 border-t border-slate-800 pt-3">
|
|
428
|
+
<p
|
|
429
|
+
v-if="prReview.error"
|
|
430
|
+
data-testid="pr-review-resume-error"
|
|
431
|
+
class="mb-2 rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
|
|
432
|
+
>
|
|
433
|
+
{{ prReview.error }}
|
|
434
|
+
</p>
|
|
435
|
+
<div class="flex items-start justify-between gap-3">
|
|
436
|
+
<p class="min-w-0 text-[11px] text-slate-500">{{ t('prReview.resume.hint') }}</p>
|
|
437
|
+
<UButton
|
|
438
|
+
data-testid="pr-review-resume"
|
|
439
|
+
size="xs"
|
|
440
|
+
color="neutral"
|
|
441
|
+
variant="soft"
|
|
442
|
+
icon="i-lucide-rotate-ccw"
|
|
443
|
+
:loading="prReview.resuming"
|
|
444
|
+
:disabled="!canResume"
|
|
445
|
+
:title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
|
|
446
|
+
@click="onResume"
|
|
447
|
+
>
|
|
448
|
+
{{ t('prReview.resume.action') }}
|
|
449
|
+
</UButton>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
398
452
|
</div>
|
|
399
453
|
|
|
400
454
|
<!-- A resolution is executing: the Fixer is committing / comments are being posted. -->
|
|
@@ -43,6 +43,7 @@ const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
|
|
|
43
43
|
{ type: 'pr_review_ready', label: t('slack.routable.pr_review_ready') },
|
|
44
44
|
{ type: 'initiative', label: t('slack.routable.initiative') },
|
|
45
45
|
{ type: 'platform_health', label: t('slack.routable.platform_health') },
|
|
46
|
+
{ type: 'infra_unreachable', label: t('slack.routable.infra_unreachable') },
|
|
46
47
|
])
|
|
47
48
|
|
|
48
49
|
/** Notification-role options for a mapped member (drives who gets @-mentioned). */
|
|
@@ -75,6 +76,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
75
76
|
budget_paused: { enabled: false, channel: '' },
|
|
76
77
|
// In-app only (not in ROUTABLE), but the map is exhaustive over the type.
|
|
77
78
|
key_drift: { enabled: false, channel: '' },
|
|
79
|
+
infra_unreachable: { enabled: false, channel: '' },
|
|
78
80
|
})
|
|
79
81
|
const mentionsEnabled = ref(false)
|
|
80
82
|
// Editable member rows carry a client-only stable `uid` (see `slackMemberMapping`) so
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
dismissPrReviewFindingContract,
|
|
4
4
|
getPrReviewContract,
|
|
5
5
|
resolvePrReviewContract,
|
|
6
|
+
resumePrReviewContract,
|
|
6
7
|
} from '@cat-factory/contracts'
|
|
7
8
|
import type { ApiContext } from './context'
|
|
8
9
|
|
|
@@ -32,6 +33,15 @@ export function prReviewApi({ send, ws }: ApiContext) {
|
|
|
32
33
|
body,
|
|
33
34
|
}),
|
|
34
35
|
|
|
36
|
+
// Re-trigger a review stuck mid-`reviewing`: only the slices that never reported are
|
|
37
|
+
// re-reviewed. No body — the engine derives what to redo from what it observed.
|
|
38
|
+
resumePrReview: (workspaceId: string, executionId: string) =>
|
|
39
|
+
send(resumePrReviewContract, {
|
|
40
|
+
pathPrefix: ws(workspaceId),
|
|
41
|
+
pathParams: { executionId },
|
|
42
|
+
body: {},
|
|
43
|
+
}),
|
|
44
|
+
|
|
35
45
|
// Dismiss a parked finding entirely (drops it + prunes it from the selection).
|
|
36
46
|
dismissPrReviewFinding: (workspaceId: string, executionId: string, findingId: string) =>
|
|
37
47
|
send(dismissPrReviewFindingContract, {
|
|
@@ -111,6 +111,12 @@ export function useWorkspaceStream() {
|
|
|
111
111
|
// inspector's "Test environment creation" control shows the live stage + final
|
|
112
112
|
// outcome in place without a refetch. No board block.
|
|
113
113
|
environmentTest.upsert(event.run)
|
|
114
|
+
} else if (event.type === 'infraSetup') {
|
|
115
|
+
// The reachability watcher found a configured infrastructure area dead (or answering again) —
|
|
116
|
+
// patch that one area so the setup banner appears/clears immediately. A full refresh would
|
|
117
|
+
// pay the whole snapshot aggregate for a one-field delta, and the projection the snapshot
|
|
118
|
+
// recomputes already folds the same recorded state.
|
|
119
|
+
workspace.patchInfraSetup(event.area, event.status, event.detail)
|
|
114
120
|
} else if (event.type === 'notification') {
|
|
115
121
|
// A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
|
|
116
122
|
// inbox + per-block badge in place (resolved ones drop out of the inbox).
|
|
@@ -186,3 +186,90 @@ describe('execution store metrics preservation (live-only rollup)', () => {
|
|
|
186
186
|
expect(step.metrics?.calls).toBe(5)
|
|
187
187
|
})
|
|
188
188
|
})
|
|
189
|
+
|
|
190
|
+
// Regression for the optimistic-echo CLOBBER. `upsert`/`hydrate` are monotonic by `rev`, but an
|
|
191
|
+
// action store's echo used to reach into the cached run and assign a step's sub-state directly,
|
|
192
|
+
// comparing nothing — so a slow HTTP response overwrote state the stream had already advanced, and
|
|
193
|
+
// no later event restored it. `echoAfter` closes that by capturing the run's `rev` before the
|
|
194
|
+
// request and re-reading it after.
|
|
195
|
+
//
|
|
196
|
+
// The fork-decision chat is the case that caught it in CI: `chat` emits the one-message `answering`
|
|
197
|
+
// state and then wakes the driver, which appends the reply and emits again. With a canned (no-model)
|
|
198
|
+
// reply the two-message thread routinely lands first, and echoing the response dropped the reply
|
|
199
|
+
// permanently — a parked run emits nothing more.
|
|
200
|
+
describe('execution store echoAfter (optimistic-echo guard)', () => {
|
|
201
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
store = useExecutionStore()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const run = (rev: number, chat: unknown[]): ExecutionInstance =>
|
|
207
|
+
({
|
|
208
|
+
id: 'e1',
|
|
209
|
+
blockId: 'b1',
|
|
210
|
+
rev,
|
|
211
|
+
currentStep: 0,
|
|
212
|
+
steps: [{ agentKind: 'coder', forkDecision: { status: 'answering', chat } }],
|
|
213
|
+
}) as unknown as ExecutionInstance
|
|
214
|
+
|
|
215
|
+
const chatOf = () =>
|
|
216
|
+
(store.getInstance('e1')!.steps[0] as unknown as { forkDecision: { chat: unknown[] } })
|
|
217
|
+
.forkDecision.chat
|
|
218
|
+
|
|
219
|
+
it('applies the echo when nothing newer arrived while the request was in flight', () => {
|
|
220
|
+
store.hydrate([run(1, ['human'])], 'ws1')
|
|
221
|
+
return store
|
|
222
|
+
.echoAfter(
|
|
223
|
+
'e1',
|
|
224
|
+
async () => ({ status: 'answering', chat: ['human', 'echoed'] }),
|
|
225
|
+
(state, instance) => {
|
|
226
|
+
;(instance.steps[0] as unknown as { forkDecision: unknown }).forkDecision = state
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
.then(() => expect(chatOf()).toEqual(['human', 'echoed']))
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('DROPS the echo when the stream delivered a newer revision first', async () => {
|
|
233
|
+
store.hydrate([run(1, ['human'])], 'ws1')
|
|
234
|
+
// The driver's reply lands (rev 2, two messages) while the chat POST is still in flight...
|
|
235
|
+
await store.echoAfter(
|
|
236
|
+
'e1',
|
|
237
|
+
async () => {
|
|
238
|
+
store.upsert(run(2, ['human', 'assistant reply']))
|
|
239
|
+
return { status: 'answering', chat: ['human'] }
|
|
240
|
+
},
|
|
241
|
+
(state, instance) => {
|
|
242
|
+
;(instance.steps[0] as unknown as { forkDecision: unknown }).forkDecision = state
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
// ...so the one-message response must not put the thread back. Unguarded, this was ['human'],
|
|
246
|
+
// the reply was gone, and the "thinking…" bubble spun forever.
|
|
247
|
+
expect(chatOf()).toEqual(['human', 'assistant reply'])
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('still returns the response body when the echo is dropped', async () => {
|
|
251
|
+
store.hydrate([run(1, [])], 'ws1')
|
|
252
|
+
const returned = await store.echoAfter(
|
|
253
|
+
'e1',
|
|
254
|
+
async () => {
|
|
255
|
+
store.upsert(run(5, ['newer']))
|
|
256
|
+
return 'body'
|
|
257
|
+
},
|
|
258
|
+
() => {
|
|
259
|
+
throw new Error('apply must not run')
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
expect(returned).toBe('body')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('skips the echo for a run the cache does not hold, rather than throwing', async () => {
|
|
266
|
+
const returned = await store.echoAfter(
|
|
267
|
+
'missing',
|
|
268
|
+
async () => 'body',
|
|
269
|
+
() => {
|
|
270
|
+
throw new Error('apply must not run')
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
expect(returned).toBe('body')
|
|
274
|
+
})
|
|
275
|
+
})
|
package/app/stores/execution.ts
CHANGED
|
@@ -130,6 +130,44 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
130
130
|
} else instances.value.push(instance)
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Run an action that returns a run's authoritative sub-state and apply that state to the cached
|
|
135
|
+
* run as an OPTIMISTIC ECHO — but only when the event stream has not delivered a newer revision
|
|
136
|
+
* while the request was in flight.
|
|
137
|
+
*
|
|
138
|
+
* WHY THIS EXISTS. {@link upsert} and {@link hydrate} are monotonic by `rev`, so a stale stream
|
|
139
|
+
* event can never regress a run. An action store's echo bypassed both: it reached into the cached
|
|
140
|
+
* instance and assigned `step.forkDecision` / `step.prReview` / `step.judge` / `step.followUps`
|
|
141
|
+
* directly, with nothing comparing revisions. That is a live-push CLOBBER in its optimistic-echo
|
|
142
|
+
* form, and it loses state that no later event restores.
|
|
143
|
+
*
|
|
144
|
+
* The fork-decision chat is the case that caught it. `chat` records the human turn and wakes the
|
|
145
|
+
* durable driver, which computes the reply and re-parks — two separate emits. With no model wired
|
|
146
|
+
* the reply is canned, so the driver routinely emits the two-message thread BEFORE the browser has
|
|
147
|
+
* even processed the HTTP response carrying the one-message `answering` state. Echoing that
|
|
148
|
+
* response then dropped the reply back off the thread, permanently: the run is parked, so nothing
|
|
149
|
+
* emits again. It read as a hung "thinking…" bubble to a user and as a flaky spec in CI.
|
|
150
|
+
*
|
|
151
|
+
* The guard is the run's own `rev`, captured BEFORE the request and re-read after. Any advance
|
|
152
|
+
* means the stream has already delivered this write (or something later), so the echo has nothing
|
|
153
|
+
* left to add and is skipped. Unchanged means the echo is still the freshest thing available,
|
|
154
|
+
* which is exactly what it is for. Taking the request as a thunk keeps the capture-then-compare
|
|
155
|
+
* ordering here rather than at four call sites that each have to remember it.
|
|
156
|
+
*/
|
|
157
|
+
async function echoAfter<T>(
|
|
158
|
+
executionId: string,
|
|
159
|
+
send: () => Promise<T>,
|
|
160
|
+
apply: (state: T, instance: ExecutionInstance) => void,
|
|
161
|
+
): Promise<T> {
|
|
162
|
+
const before = byId.value.get(executionId)
|
|
163
|
+
const revBefore = before ? revOf(before) : -1
|
|
164
|
+
const state = await send()
|
|
165
|
+
const instance = byId.value.get(executionId)
|
|
166
|
+
if (!instance || revOf(instance) !== revBefore) return state
|
|
167
|
+
apply(state, instance)
|
|
168
|
+
return state
|
|
169
|
+
}
|
|
170
|
+
|
|
133
171
|
const byId = computed(() => {
|
|
134
172
|
const map = new Map<string, ExecutionInstance>()
|
|
135
173
|
for (const e of instances.value) map.set(e.id, e)
|
|
@@ -248,6 +286,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
248
286
|
instances,
|
|
249
287
|
hydrate,
|
|
250
288
|
upsert,
|
|
289
|
+
echoAfter,
|
|
251
290
|
byId,
|
|
252
291
|
getInstance,
|
|
253
292
|
getByBlock,
|
package/app/stores/followUps.ts
CHANGED
|
@@ -33,7 +33,7 @@ export const useFollowUpsStore = defineStore('followUps', () => {
|
|
|
33
33
|
acting.value = next
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** Run one decide action,
|
|
36
|
+
/** Run one decide action, echoing the returned state onto the run's Coder step. */
|
|
37
37
|
async function act(
|
|
38
38
|
executionId: string,
|
|
39
39
|
itemId: string,
|
|
@@ -42,13 +42,19 @@ export const useFollowUpsStore = defineStore('followUps', () => {
|
|
|
42
42
|
error.value = null
|
|
43
43
|
mark(itemId, true)
|
|
44
44
|
try {
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
// Echo the authoritative state immediately (the stream also delivers it), but only when the
|
|
46
|
+
// stream has not already delivered something NEWER — deciding a follow-up can re-arm the run,
|
|
47
|
+
// so the driver emits while this response is still in flight. See `execution.echoAfter`.
|
|
48
|
+
await execution.echoAfter(
|
|
49
|
+
executionId,
|
|
50
|
+
() => call(workspace.requireId()),
|
|
51
|
+
(state, instance) => {
|
|
52
|
+
const step = instance.steps.find((s) => s.followUps?.enabled)
|
|
53
|
+
if (step && state && typeof state === 'object') {
|
|
54
|
+
step.followUps = state as typeof step.followUps
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
)
|
|
52
58
|
} catch (e) {
|
|
53
59
|
error.value = e instanceof Error ? e.message : 'Action failed'
|
|
54
60
|
throw e
|