@cat-factory/app 0.35.0 → 0.36.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/gates/GateResultView.vue +107 -12
- package/app/components/layout/NotificationsInbox.vue +16 -0
- package/app/components/slack/SlackPanel.vue +1 -0
- package/app/composables/api/humanReview.ts +18 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/humanReview.ts +41 -0
- package/app/types/domain.ts +4 -0
- package/app/types/execution.ts +16 -0
- package/app/types/notifications.ts +1 -0
- package/app/utils/catalog.ts +12 -0
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// persists on `step.gate`: the precheck verdict, the helper attempt budget, the gated
|
|
6
6
|
// commit, and — for CI — the failing checks behind the failure. One window serves both
|
|
7
7
|
// gates; it branches on the step's `agentKind` for the copy and the failure detail.
|
|
8
|
-
import { computed } from 'vue'
|
|
8
|
+
import { computed, ref } from 'vue'
|
|
9
9
|
import { agentKindMeta } from '~/utils/catalog'
|
|
10
10
|
import type { GateStepState } from '~/types/execution'
|
|
11
11
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
@@ -30,10 +30,37 @@ const step = computed(() => {
|
|
|
30
30
|
const gate = computed<GateStepState | null>(() => step.value?.gate ?? null)
|
|
31
31
|
|
|
32
32
|
const isCi = computed(() => step.value?.agentKind === 'ci')
|
|
33
|
+
const isHumanReview = computed(() => step.value?.agentKind === 'human-review')
|
|
33
34
|
const meta = computed(() => agentKindMeta(step.value?.agentKind ?? 'ci'))
|
|
34
|
-
const helperKind = computed(() =>
|
|
35
|
+
const helperKind = computed(() =>
|
|
36
|
+
isHumanReview.value ? 'fixer' : isCi.value ? 'ci-fixer' : 'conflict-resolver',
|
|
37
|
+
)
|
|
35
38
|
const helperMeta = computed(() => agentKindMeta(helperKind.value))
|
|
36
39
|
|
|
40
|
+
const subtitle = computed(() =>
|
|
41
|
+
isHumanReview.value
|
|
42
|
+
? 'Waits for a human code review on the PR, looping the fixer on comments'
|
|
43
|
+
: isCi.value
|
|
44
|
+
? 'Gates the PR on green CI, looping the CI fixer on failure'
|
|
45
|
+
: 'Gates the PR on a clean merge, looping the resolver on conflicts',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// Human-review: approval progress + the freeform "request a fix" control.
|
|
49
|
+
const humanReview = useHumanReviewStore()
|
|
50
|
+
const fixInstructions = ref('')
|
|
51
|
+
const fixBusy = computed(() => (blockId.value ? humanReview.isBusy(blockId.value) : false))
|
|
52
|
+
async function submitFix() {
|
|
53
|
+
const id = blockId.value
|
|
54
|
+
const text = fixInstructions.value.trim()
|
|
55
|
+
if (!id || !text) return
|
|
56
|
+
await humanReview.requestFix(id, text)
|
|
57
|
+
fixInstructions.value = ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The displayed "required approvals" is derived from the cached branch-protection count via
|
|
61
|
+
// the gate's effective floor (`max(1, …)`, see review.logic.ts) rather than persisted twice.
|
|
62
|
+
const requiredApprovals = computed(() => Math.max(1, gate.value?.requiredApprovingReviewCount ?? 1))
|
|
63
|
+
|
|
37
64
|
const failingChecks = computed(() => gate.value?.failingChecks ?? [])
|
|
38
65
|
const shortSha = computed(() => (gate.value?.headSha ? gate.value.headSha.slice(0, 7) : null))
|
|
39
66
|
|
|
@@ -130,13 +157,7 @@ const conflictVerdict = computed(() => {
|
|
|
130
157
|
<h2 class="truncate text-sm font-semibold text-slate-100">
|
|
131
158
|
{{ meta.label }}{{ block ? ` — ${block.title}` : '' }}
|
|
132
159
|
</h2>
|
|
133
|
-
<p class="truncate text-[11px] text-slate-400">
|
|
134
|
-
{{
|
|
135
|
-
isCi
|
|
136
|
-
? 'Gates the PR on green CI, looping the CI fixer on failure'
|
|
137
|
-
: 'Gates the PR on a clean merge, looping the resolver on conflicts'
|
|
138
|
-
}}
|
|
139
|
-
</p>
|
|
160
|
+
<p class="truncate text-[11px] text-slate-400">{{ subtitle }}</p>
|
|
140
161
|
</div>
|
|
141
162
|
<UBadge :color="STATUS_META[status].badge" variant="subtle" size="sm">
|
|
142
163
|
{{ STATUS_META[status].label }}
|
|
@@ -186,6 +207,73 @@ const conflictVerdict = computed(() => {
|
|
|
186
207
|
</p>
|
|
187
208
|
</div>
|
|
188
209
|
|
|
210
|
+
<!-- Human review: approval progress, the feedback being fixed, freeform fix box -->
|
|
211
|
+
<template v-else-if="isHumanReview">
|
|
212
|
+
<div
|
|
213
|
+
class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2"
|
|
214
|
+
>
|
|
215
|
+
<UIcon name="i-lucide-users" class="h-4 w-4 shrink-0 text-violet-300" />
|
|
216
|
+
<span class="text-[13px] text-slate-200">
|
|
217
|
+
{{ gate.lastApprovals ?? 0 }} / {{ requiredApprovals }} approval{{
|
|
218
|
+
requiredApprovals === 1 ? '' : 's'
|
|
219
|
+
}}
|
|
220
|
+
<template v-if="status === 'fixing'"> · fixer addressing comments…</template>
|
|
221
|
+
<template v-else-if="status === 'failing'">
|
|
222
|
+
· review comments to address</template
|
|
223
|
+
>
|
|
224
|
+
<template v-else> · awaiting review</template>
|
|
225
|
+
</span>
|
|
226
|
+
</div>
|
|
227
|
+
<p
|
|
228
|
+
v-if="gate.lastFailureSummary"
|
|
229
|
+
class="mt-2 whitespace-pre-wrap rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] leading-relaxed text-slate-300"
|
|
230
|
+
>
|
|
231
|
+
{{ gate.lastFailureSummary }}
|
|
232
|
+
</p>
|
|
233
|
+
<a
|
|
234
|
+
v-if="prUrl"
|
|
235
|
+
:href="prUrl"
|
|
236
|
+
target="_blank"
|
|
237
|
+
rel="noopener"
|
|
238
|
+
class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200 hover:underline"
|
|
239
|
+
>
|
|
240
|
+
Review pull request on GitHub
|
|
241
|
+
<UIcon name="i-lucide-external-link" class="h-3 w-3" />
|
|
242
|
+
</a>
|
|
243
|
+
|
|
244
|
+
<!-- Freeform fix request: dispatch the fixer now with these instructions. -->
|
|
245
|
+
<section v-if="status !== 'gave-up'" class="mt-4">
|
|
246
|
+
<h3
|
|
247
|
+
class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
|
|
248
|
+
>
|
|
249
|
+
Request a fix
|
|
250
|
+
</h3>
|
|
251
|
+
<p class="mb-2 text-[11px] leading-relaxed text-slate-500">
|
|
252
|
+
Describe a change for the fixer to make on the PR branch now (in addition to any
|
|
253
|
+
review comments, which it addresses automatically).
|
|
254
|
+
</p>
|
|
255
|
+
<textarea
|
|
256
|
+
v-model="fixInstructions"
|
|
257
|
+
rows="3"
|
|
258
|
+
:disabled="fixBusy"
|
|
259
|
+
placeholder="e.g. rename the helper and add a unit test for the empty-input case"
|
|
260
|
+
class="w-full resize-y rounded-md border border-slate-800 bg-slate-950/60 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-violet-500/60 focus:outline-none"
|
|
261
|
+
/>
|
|
262
|
+
<div class="mt-2 flex justify-end">
|
|
263
|
+
<UButton
|
|
264
|
+
size="sm"
|
|
265
|
+
color="primary"
|
|
266
|
+
icon="i-lucide-wrench"
|
|
267
|
+
:loading="fixBusy"
|
|
268
|
+
:disabled="fixBusy || fixInstructions.trim().length === 0"
|
|
269
|
+
@click="submitFix"
|
|
270
|
+
>
|
|
271
|
+
Request fix
|
|
272
|
+
</UButton>
|
|
273
|
+
</div>
|
|
274
|
+
</section>
|
|
275
|
+
</template>
|
|
276
|
+
|
|
189
277
|
<!-- CI: failing checks -->
|
|
190
278
|
<template v-else-if="isCi">
|
|
191
279
|
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
@@ -321,9 +409,16 @@ const conflictVerdict = computed(() => {
|
|
|
321
409
|
{{ helperMeta.label }}
|
|
322
410
|
</h4>
|
|
323
411
|
<p class="text-[12px] text-slate-300">
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
412
|
+
<!-- The human-review gate's budget is effectively unbounded (it waits for a human
|
|
413
|
+
indefinitely), so render a plain round count rather than "0/9007199254740991". -->
|
|
414
|
+
<template v-if="isHumanReview">
|
|
415
|
+
{{ gate.attempts }} fix round{{ gate.attempts === 1 ? '' : 's' }}
|
|
416
|
+
</template>
|
|
417
|
+
<template v-else>
|
|
418
|
+
{{ gate.attempts }}/{{ gate.maxAttempts }} attempt{{
|
|
419
|
+
gate.maxAttempts === 1 ? '' : 's'
|
|
420
|
+
}}
|
|
421
|
+
</template>
|
|
327
422
|
<template v-if="gate.phase === 'working'"> · running…</template>
|
|
328
423
|
<template v-else-if="gate.attempts === 0"> · not needed yet</template>
|
|
329
424
|
</p>
|
|
@@ -36,6 +36,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent; action:
|
|
|
36
36
|
// Clicking the title opens the human-testing window for the task (see `reveal`); "act" just
|
|
37
37
|
// marks it read (the gate is resolved in that window — confirm / request a fix — not here).
|
|
38
38
|
human_test_ready: { icon: 'i-lucide-user-check', color: 'primary', action: 'Mark read' },
|
|
39
|
+
// Clicking the title opens the task's gate window (where the human can request a freeform
|
|
40
|
+
// fix); "act" just marks it read (approval happens on GitHub, not here).
|
|
41
|
+
human_review: { icon: 'i-lucide-users', color: 'primary', action: 'Mark read' },
|
|
39
42
|
// Clicking the title opens the Follow-up companion window for the run (see `reveal`); "act"
|
|
40
43
|
// just marks it read (items are decided in that window — file / send back / answer — not here).
|
|
41
44
|
followup_pending: { icon: 'i-lucide-compass', color: 'warning', action: 'Mark read' },
|
|
@@ -84,10 +87,23 @@ function reveal(n: Notification) {
|
|
|
84
87
|
else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
|
|
85
88
|
else if (n.type === 'decision_required') revealDecision(n)
|
|
86
89
|
else if (n.type === 'human_test_ready') revealHumanTest(n)
|
|
90
|
+
else if (n.type === 'human_review') revealHumanReview(n)
|
|
87
91
|
else if (n.type === 'followup_pending') revealFollowUps(n)
|
|
88
92
|
else ui.select(n.blockId)
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Open the gate window for a parked `human-review` gate: find the run's human-review step and
|
|
97
|
+
* open it through the universal step dispatch (its archetype declares the `gate` result view,
|
|
98
|
+
* where the human can request a freeform fix). Falls back to focusing the block.
|
|
99
|
+
*/
|
|
100
|
+
function revealHumanReview(n: Notification) {
|
|
101
|
+
const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
|
|
102
|
+
const idx = instance?.steps.findIndex((s) => s.agentKind === 'human-review') ?? -1
|
|
103
|
+
if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
|
|
104
|
+
else if (n.blockId) ui.select(n.blockId)
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
/**
|
|
92
108
|
* Open the Follow-up companion window for a run whose Coder parked on undecided items.
|
|
93
109
|
* Falls back to focusing the block when the run isn't loaded.
|
|
@@ -45,6 +45,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
45
45
|
// In-app only (not in ROUTABLE), but the map is exhaustive over the type.
|
|
46
46
|
decision_required: { enabled: false, channel: '' },
|
|
47
47
|
human_test_ready: { enabled: false, channel: '' },
|
|
48
|
+
human_review: { enabled: false, channel: '' },
|
|
48
49
|
followup_pending: { enabled: false, channel: '' },
|
|
49
50
|
})
|
|
50
51
|
const mentionsEnabled = ref(false)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ExecutionInstance } from '~/types/domain'
|
|
2
|
+
import type { ApiContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The human-review gate's run-driving action. The gate self-drives off the PR's GitHub review
|
|
6
|
+
* state, but a human can request a freeform fix at any time — dispatched to the `fixer`
|
|
7
|
+
* immediately. Returns the updated execution instance (the gate state rides on its step + the
|
|
8
|
+
* execution stream).
|
|
9
|
+
*/
|
|
10
|
+
export function humanReviewApi({ http, ws }: ApiContext) {
|
|
11
|
+
return {
|
|
12
|
+
requestHumanReviewFix: (workspaceId: string, blockId: string, instructions: string) =>
|
|
13
|
+
http<ExecutionInstance>(
|
|
14
|
+
`${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/human-review/request-fix`,
|
|
15
|
+
{ method: 'POST', body: { instructions } },
|
|
16
|
+
),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -9,6 +9,7 @@ import { executionApi } from './api/execution'
|
|
|
9
9
|
import { followUpsApi } from './api/followUps'
|
|
10
10
|
import { fragmentsApi } from './api/fragments'
|
|
11
11
|
import { githubApi } from './api/github'
|
|
12
|
+
import { humanReviewApi } from './api/humanReview'
|
|
12
13
|
import { humanTestApi } from './api/humanTest'
|
|
13
14
|
import { kaizenApi } from './api/kaizen'
|
|
14
15
|
import { localSettingsApi } from './api/localSettings'
|
|
@@ -89,6 +90,7 @@ export function useApi() {
|
|
|
89
90
|
...reviewsApi(ctx),
|
|
90
91
|
...followUpsApi(ctx),
|
|
91
92
|
...humanTestApi(ctx),
|
|
93
|
+
...humanReviewApi(ctx),
|
|
92
94
|
...kaizenApi(ctx),
|
|
93
95
|
...localSettingsApi(ctx),
|
|
94
96
|
...specApi(ctx),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Human-review gate actions. The gate's live state rides on its execution step (`step.gate`) and
|
|
8
|
+
* arrives via the execution stream, so this store holds NO gate state — it only drives the
|
|
9
|
+
* freeform "request a fix" action and patches the execution store from the response. A per-block
|
|
10
|
+
* `busy` flag lets the window disable its control while the request is in flight.
|
|
11
|
+
*/
|
|
12
|
+
export const useHumanReviewStore = defineStore('humanReview', () => {
|
|
13
|
+
const api = useApi()
|
|
14
|
+
const ws = useWorkspaceStore()
|
|
15
|
+
const execution = useExecutionStore()
|
|
16
|
+
|
|
17
|
+
const busy = ref<Set<string>>(new Set())
|
|
18
|
+
|
|
19
|
+
function isBusy(blockId: string): boolean {
|
|
20
|
+
return busy.value.has(blockId)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Dispatch the fixer against the PR from a human's freeform instructions (bypasses grace). */
|
|
24
|
+
async function requestFix(blockId: string, instructions: string): Promise<void> {
|
|
25
|
+
const next = new Set(busy.value)
|
|
26
|
+
next.add(blockId)
|
|
27
|
+
busy.value = next
|
|
28
|
+
try {
|
|
29
|
+
const instance = await api.requestHumanReviewFix(ws.requireId(), blockId, instructions)
|
|
30
|
+
if (instance && typeof instance === 'object' && 'steps' in instance) {
|
|
31
|
+
execution.upsert(instance as Parameters<typeof execution.upsert>[0])
|
|
32
|
+
}
|
|
33
|
+
} finally {
|
|
34
|
+
const after = new Set(busy.value)
|
|
35
|
+
after.delete(blockId)
|
|
36
|
+
busy.value = after
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { isBusy, requestFix }
|
|
41
|
+
})
|
package/app/types/domain.ts
CHANGED
|
@@ -315,6 +315,10 @@ export type AgentKind =
|
|
|
315
315
|
// validate the change in a live URL, dispatching the Tester's `fixer` (from findings) or
|
|
316
316
|
// the `conflict-resolver` (on a conflicting pull-main) on demand. Opens its own window.
|
|
317
317
|
| 'human-test'
|
|
318
|
+
// The human-review gate: watches the PR for a human code review on GitHub, looping the
|
|
319
|
+
// `fixer` to address review comments and advancing once approved with no unresolved threads.
|
|
320
|
+
// Opens the shared gate window (with a freeform "request a fix" box).
|
|
321
|
+
| 'human-review'
|
|
318
322
|
// The Kaizen agent: post-run grader (NOT a pipeline step / palette archetype). Surfaced
|
|
319
323
|
// only in Model Configuration (its model is pinnable like any agent) and run details.
|
|
320
324
|
| 'kaizen'
|
package/app/types/execution.ts
CHANGED
|
@@ -464,6 +464,22 @@ export interface GateStepState {
|
|
|
464
464
|
failingChecks?: GateFailingCheck[] | null
|
|
465
465
|
/** history of the helper-agent attempts this gate dispatched, newest last */
|
|
466
466
|
attemptLog?: GateAttempt[] | null
|
|
467
|
+
// ---- human-review gate only ----
|
|
468
|
+
/** approvals the PR had at the last probe (human-review gate) */
|
|
469
|
+
lastApprovals?: number | null
|
|
470
|
+
/**
|
|
471
|
+
* raw branch-protection required count, cached after the first probe (human-review gate).
|
|
472
|
+
* The displayed "required" count is `max(1, this)` — the gate's effective floor.
|
|
473
|
+
*/
|
|
474
|
+
requiredApprovingReviewCount?: number | null
|
|
475
|
+
/** review threads handed to the fixer, pending resolve on its completion (human-review gate) */
|
|
476
|
+
pendingThreadIds?: string[] | null
|
|
477
|
+
/** the grace window (minutes) before the fixer addresses a comment batch (human-review gate) */
|
|
478
|
+
humanReviewGraceMinutes?: number | null
|
|
479
|
+
/** newest plain PR comment already handed to the fixer (human-review gate) */
|
|
480
|
+
lastAddressedCommentAt?: number | null
|
|
481
|
+
/** a human-initiated freeform fix parked on the gate, consumed on the next poll (human-review gate) */
|
|
482
|
+
pendingFix?: { instructions: string; at: number } | null
|
|
467
483
|
}
|
|
468
484
|
|
|
469
485
|
/** Live state of a `tester` step's Tester→Fixer loop (mirrors `testerStepStateSchema`). */
|
package/app/utils/catalog.ts
CHANGED
|
@@ -352,6 +352,18 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
|
|
|
352
352
|
color: '#a3e635',
|
|
353
353
|
description: 'Scores the PR and auto-merges within the task thresholds, or asks for review.',
|
|
354
354
|
},
|
|
355
|
+
'human-review': {
|
|
356
|
+
kind: 'human-review',
|
|
357
|
+
label: 'Human Review Gate',
|
|
358
|
+
icon: 'i-lucide-users',
|
|
359
|
+
color: '#c084fc',
|
|
360
|
+
category: 'gates',
|
|
361
|
+
description:
|
|
362
|
+
'Waits for a human code review on the PR, looping the fixer to address comments; advances once approved with no unresolved threads.',
|
|
363
|
+
// Opens the dedicated gate window (approval progress, the feedback being fixed, and a
|
|
364
|
+
// freeform "request a fix" box) like the other gates.
|
|
365
|
+
resultView: 'gate',
|
|
366
|
+
},
|
|
355
367
|
// The Kaizen agent grades agent steps AFTER a run completes (continuous improvement).
|
|
356
368
|
// It is NOT a pipeline step (never in the palette — no `category`), but it runs an LLM,
|
|
357
369
|
// so it needs display metadata here and a per-workspace model in Model Configuration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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",
|