@cat-factory/app 0.191.0 → 0.193.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/board/AddTaskModal.vue +7 -5
- package/app/components/board/RecurringPipelineModal.vue +11 -6
- package/app/components/focus/BlockFocusView.vue +11 -3
- package/app/components/panels/InspectorPanel.vue +15 -4
- package/app/components/panels/inspector/TaskRunSettings.vue +5 -1
- package/app/components/prReview/PrReviewWindow.vue +55 -1
- package/app/composables/api/prReview.ts +10 -0
- package/app/composables/usePipelineHealth.spec.ts +32 -0
- package/app/composables/usePipelineHealth.ts +23 -4
- 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/utils/pipeline.spec.ts +85 -5
- package/app/utils/pipeline.ts +32 -11
- package/i18n/locales/de.json +4 -0
- package/i18n/locales/en.json +4 -0
- package/i18n/locales/es.json +4 -0
- package/i18n/locales/fr.json +4 -0
- package/i18n/locales/he.json +4 -0
- package/i18n/locales/it.json +4 -0
- package/i18n/locales/ja.json +4 -0
- package/i18n/locales/pl.json +4 -0
- package/i18n/locales/tr.json +4 -0
- package/i18n/locales/uk.json +4 -0
- package/package.json +2 -2
|
@@ -387,13 +387,15 @@ const fragmentPool = computed(() => fragments.forBlockType(frame.value?.type ??
|
|
|
387
387
|
|
|
388
388
|
// Hide UI-testing pipelines (`tester-ui` / `visual-confirmation`) when the target frame has no
|
|
389
389
|
// UI to exercise — they'd be refused server-side (see utils/pipeline + the backend gate). Also
|
|
390
|
-
// hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and
|
|
391
|
-
//
|
|
392
|
-
// a
|
|
393
|
-
//
|
|
390
|
+
// hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and every
|
|
391
|
+
// pipeline whose purpose doesn't match the chosen task type (a doc task authors a doc, a review task
|
|
392
|
+
// reviews a PR, and a `feature`/`bug` task ships code — so it is offered build + research only, not
|
|
393
|
+
// the doc/review/planning presets). `blockLevel: 'task'` is passed literally because this modal only
|
|
394
|
+
// ever creates a task leaf, which also drops the three planning presets the backend would refuse.
|
|
395
|
+
// Re-filters as the chosen task type changes.
|
|
394
396
|
const selectablePipelines = computed(() =>
|
|
395
397
|
pipelines.pipelines.filter((p) =>
|
|
396
|
-
pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value),
|
|
398
|
+
pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value, 'task'),
|
|
397
399
|
),
|
|
398
400
|
)
|
|
399
401
|
// Some task types want their type-default pipeline surfaced in the modal up front, so picking the
|
|
@@ -74,9 +74,15 @@ const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
|
|
|
74
74
|
|
|
75
75
|
// Infer the template from the picked pipeline so the backend seeds the right block
|
|
76
76
|
// description (and so we know to show the tracker config).
|
|
77
|
+
//
|
|
78
|
+
// Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
|
|
79
|
+
// way. `dep-update` no longer can: its pipeline was retired in the catalog collapse (it was the
|
|
80
|
+
// ordinary build tail under a recurring name), so a dependency-update schedule now runs an ordinary
|
|
81
|
+
// build rung — which is also what every generic schedule runs, so inferring the template from it
|
|
82
|
+
// would mislabel all of them. The template itself survives for an explicit API caller; see
|
|
83
|
+
// `scheduleTemplateSchema`.
|
|
77
84
|
const template = computed<ScheduleTemplate>(() => {
|
|
78
85
|
if (pipelineId.value === 'pl_tech_debt') return 'tech-debt'
|
|
79
|
-
if (pipelineId.value === 'pl_dep_update') return 'dep-update'
|
|
80
86
|
if (pipelineId.value === 'pl_bug_triage') return 'bug-triage'
|
|
81
87
|
return 'custom'
|
|
82
88
|
})
|
|
@@ -99,11 +105,10 @@ watch(open, (isOpen) => {
|
|
|
99
105
|
if (!isOpen) return
|
|
100
106
|
name.value = ''
|
|
101
107
|
description.value = ''
|
|
102
|
-
// Default to the
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
''
|
|
108
|
+
// Default to the first schedulable pipeline, which is the ladder's own default rung. There is no
|
|
109
|
+
// longer a canned recurring build preset to prefer — the dependency-update pipeline was the
|
|
110
|
+
// ordinary build tail under another name — so the default build is the honest starting point.
|
|
111
|
+
pipelineId.value = selectablePipelines.value[0]?.id ?? pipelines.pipelines[0]?.id ?? ''
|
|
107
112
|
recurrence.value = defaultRecurrence()
|
|
108
113
|
onDemand.value = false
|
|
109
114
|
saving.value = false
|
|
@@ -30,12 +30,20 @@ const deps = computed(() =>
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
// Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
|
|
33
|
-
// pipelines (a manual run of one is refused server-side), and
|
|
34
|
-
//
|
|
33
|
+
// pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
|
|
34
|
+
// match this block's task type or LEVEL (per the `purpose` classifier) — see the backend gate. The
|
|
35
|
+
// level is what keeps the planning presets on initiative blocks and off everything else, in both
|
|
36
|
+
// directions, so this menu never offers a run the engine answers with a 409.
|
|
35
37
|
const runOptions = computed(() => {
|
|
36
38
|
const frame = block.value ? board.serviceOf(block.value) : undefined
|
|
37
39
|
return pipelines.pipelines.filter((p) =>
|
|
38
|
-
pipelineAllowedForManualStart(
|
|
40
|
+
pipelineAllowedForManualStart(
|
|
41
|
+
p,
|
|
42
|
+
frame,
|
|
43
|
+
board.blocks,
|
|
44
|
+
block.value?.taskType,
|
|
45
|
+
block.value?.level,
|
|
46
|
+
),
|
|
39
47
|
)
|
|
40
48
|
})
|
|
41
49
|
|
|
@@ -178,13 +178,24 @@ const taskBranchUrl = computed(() => {
|
|
|
178
178
|
return base ? `${base}/tree/${pr.branch}` : null
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
-
// Hide UI-testing pipelines when this block's frame has no UI to exercise,
|
|
182
|
-
// pipelines (a manual run of one is refused server-side)
|
|
183
|
-
// utils/pipeline + the
|
|
181
|
+
// Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
|
|
182
|
+
// pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
|
|
183
|
+
// match this block's task type or LEVEL — they'd be refused at run start (see utils/pipeline + the
|
|
184
|
+
// backend gate). The level is what keeps the planning presets on initiative blocks and off
|
|
185
|
+
// everything else, so this menu never offers a run the engine answers with a 409; it applies to
|
|
186
|
+
// frames and modules too, which is why it reads `block.level` rather than assuming a task.
|
|
184
187
|
const runMenu = computed(() => {
|
|
185
188
|
const frame = block.value ? board.serviceOf(block.value) : undefined
|
|
186
189
|
return pipelines.pipelines
|
|
187
|
-
.filter((p) =>
|
|
190
|
+
.filter((p) =>
|
|
191
|
+
pipelineAllowedForManualStart(
|
|
192
|
+
p,
|
|
193
|
+
frame,
|
|
194
|
+
board.blocks,
|
|
195
|
+
block.value?.taskType,
|
|
196
|
+
block.value?.level,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
188
199
|
.map((p) => ({
|
|
189
200
|
label: p.name,
|
|
190
201
|
icon: 'i-lucide-play',
|
|
@@ -144,10 +144,14 @@ const selectedPipeline = computed(() =>
|
|
|
144
144
|
// pipelines (the task's manual Run control can't start one), and — for a `document` task — every
|
|
145
145
|
// non-document pipeline (it authors a doc, so a build/test pipeline makes no sense). All would be
|
|
146
146
|
// refused / wrong at run start (see utils/pipeline + the backend gate + the purpose classifier).
|
|
147
|
+
// `blockLevel: 'task'` is passed literally because this panel only ever edits a task leaf, which
|
|
148
|
+
// drops the planning presets the engine would refuse. The task-type narrowing already excludes them
|
|
149
|
+
// for `feature`/`bug`, but NOT for a `spike` / `ralph` / deployment-custom type — and a planning
|
|
150
|
+
// preset settable as a task's DEFAULT pipeline is a 409 on every later Start.
|
|
147
151
|
const taskFrame = computed(() => board.serviceOf(props.block))
|
|
148
152
|
const selectablePipelines = computed(() =>
|
|
149
153
|
pipelines.pipelines.filter((p) =>
|
|
150
|
-
pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType),
|
|
154
|
+
pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType, 'task'),
|
|
151
155
|
),
|
|
152
156
|
)
|
|
153
157
|
function setPipeline(id: string) {
|
|
@@ -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. -->
|
|
@@ -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, {
|
|
@@ -140,6 +140,38 @@ describe('usePipelineHealth', () => {
|
|
|
140
140
|
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
+
// The regression this pins: the advisory carried its own "only a companion may be gated" rule,
|
|
144
|
+
// so when the engine generalised gating to `BUILTIN_GATABLE_KINDS` the shipped `pl_simple`
|
|
145
|
+
// ("Adaptive build" — an estimate-gated `architect`) was reported invalid in EVERY workspace.
|
|
146
|
+
// Because the advisory auto-opens a modal over the board, that made the board unusable rather
|
|
147
|
+
// than merely warning wrongly. Both sides now read the shared contracts constant.
|
|
148
|
+
it('accepts an estimate-gated NON-companion producer that the shared gatable set allows', () => {
|
|
149
|
+
const adaptive = builtin(['task-estimator', 'architect', 'architect-companion', 'coder'], {
|
|
150
|
+
gating: [null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }, null, null],
|
|
151
|
+
})
|
|
152
|
+
const { hasIssues } = scan([adaptive])
|
|
153
|
+
expect(hasIssues.value).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('still flags an estimate-gated kind the shared gatable set excludes (merger)', () => {
|
|
157
|
+
const gatedMerger = builtin(['task-estimator', 'coder', 'merger'], {
|
|
158
|
+
gating: [null, null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }],
|
|
159
|
+
})
|
|
160
|
+
const { invalid } = scan([gatedMerger])
|
|
161
|
+
expect(invalid.value).toHaveLength(1)
|
|
162
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('flags a step carrying BOTH a human approval gate and an estimate gate (shape)', () => {
|
|
166
|
+
const both = builtin(['task-estimator', 'architect'], {
|
|
167
|
+
gates: [false, true],
|
|
168
|
+
gating: [null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }],
|
|
169
|
+
})
|
|
170
|
+
const { invalid } = scan([both])
|
|
171
|
+
expect(invalid.value).toHaveLength(1)
|
|
172
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
143
175
|
it('reports a built-in whose catalog version moved ahead as outdated (not invalid)', () => {
|
|
144
176
|
const stale = builtin(['coder', 'reviewer'], { id: 'pl_stale', version: 1 })
|
|
145
177
|
const { invalid, outdated } = scan([stale], { pl_stale: 2 })
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed } from 'vue'
|
|
2
2
|
import type { Pipeline } from '~/types/domain'
|
|
3
3
|
import type { StepGating } from '~/types/consensus'
|
|
4
|
+
import { isBuiltinGatableKind } from '@cat-factory/contracts'
|
|
4
5
|
import { COMPANION_FOR_PRODUCER, isKnownAgentKind, isProducerCompanion } from '~/utils/catalog'
|
|
5
6
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
6
7
|
|
|
@@ -78,6 +79,11 @@ const isEnabledAt = (p: Pipeline, i: number) => p.enabled?.[i] !== false
|
|
|
78
79
|
* gating, over the ENABLED subset), collecting the first problem instead of throwing. Returns a
|
|
79
80
|
* human message, or null when the shape is valid. Kept in step with
|
|
80
81
|
* `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
|
|
82
|
+
*
|
|
83
|
+
* A rule here must be keyed off vocabulary SHARED with that module (`@cat-factory/contracts`)
|
|
84
|
+
* wherever one exists, never re-stated locally — see the gating note below for what a drifted copy
|
|
85
|
+
* costs. Adding a rule to `assertValidGating` without adding it here is the milder half of the same
|
|
86
|
+
* drift: a pipeline the engine refuses at save that this advisory calls healthy.
|
|
81
87
|
*/
|
|
82
88
|
function shapeProblem(p: Pipeline): string | null {
|
|
83
89
|
const kinds = p.agentKinds
|
|
@@ -102,16 +108,29 @@ function shapeProblem(p: Pipeline): string | null {
|
|
|
102
108
|
return `Companion '${kind}' must run immediately after an enabled step it can review (${targets.join(', ')}).`
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
|
-
// Estimate gating: an enabled gated step must be a
|
|
106
|
-
// enabled task-estimator earlier in the
|
|
111
|
+
// Estimate gating: an enabled gated step must be a GATABLE kind, must not also carry a human
|
|
112
|
+
// approval gate, must set ≥1 threshold, and must have an enabled task-estimator earlier in the
|
|
113
|
+
// chain. Gatability reads the SHARED `BUILTIN_GATABLE_KINDS` rather than a local rule, because
|
|
114
|
+
// this advisory auto-opens a modal over the board: a copy of the rule that drifts behind the
|
|
115
|
+
// engine's does not merely warn wrongly, it calls a pipeline the product SHIPS invalid and leaves
|
|
116
|
+
// the board unusable. A DEPLOYMENT-registered kind can override gatability for itself through the
|
|
117
|
+
// agent-kind registry, which the SPA cannot see, so the two are not perfectly symmetric: such a
|
|
118
|
+
// kind is reported here and accepted by the engine. That is the safe direction of the asymmetry —
|
|
119
|
+
// a dismissible advisory rather than a refused save — and the only one available without shipping
|
|
120
|
+
// the registry to the browser.
|
|
107
121
|
const gating = p.gating
|
|
108
122
|
if (gating) {
|
|
109
123
|
for (let i = 0; i < kinds.length; i++) {
|
|
110
124
|
const g = gating[i] as StepGating | null | undefined
|
|
111
125
|
if (!g?.enabled || !isEnabledAt(p, i)) continue
|
|
112
126
|
const kind = kinds[i]
|
|
113
|
-
if (!kind || !
|
|
114
|
-
return `Step '${kind}'
|
|
127
|
+
if (!kind || !isBuiltinGatableKind(kind)) {
|
|
128
|
+
return `Step '${kind}' may not be estimate-gated — its output is required by the rest of the run. Only a step whose result later steps read as context (a design, a review, an extra verification pass) may be skipped on the estimate.`
|
|
129
|
+
}
|
|
130
|
+
// A human approval gate and an estimate gate on the same step contradict: the estimate may
|
|
131
|
+
// ADD a human checkpoint but never CANCEL a pause the pipeline author asked for.
|
|
132
|
+
if (p.gates?.[i] === true) {
|
|
133
|
+
return `Step '${kind}' carries a human approval gate, so it cannot also be estimate-gated — the estimate may add a human checkpoint but never remove one.`
|
|
115
134
|
}
|
|
116
135
|
if (g.minComplexity === undefined && g.minRisk === undefined && g.minImpact === undefined) {
|
|
117
136
|
return `Step '${kind}' is estimate-gated but sets no threshold (complexity / risk / impact).`
|
|
@@ -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
|
|
@@ -26,17 +26,20 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
|
|
|
26
26
|
const error = ref<string | null>(null)
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* Apply an authoritative fork-decision state to the run's Coder step. A pipeline may
|
|
30
30
|
* carry more than one `coder` step, so target the step this decision is about rather than
|
|
31
31
|
* the first one that happens to hold fork state: prefer the step that is still live
|
|
32
32
|
* (proposing / awaiting the choice / answering), then the current step, and only then fall
|
|
33
|
-
* back to the first step carrying fork state.
|
|
34
|
-
*
|
|
33
|
+
* back to the first step carrying fork state.
|
|
34
|
+
*
|
|
35
|
+
* Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
|
|
36
|
+
* stream already delivered a newer revision — without that guard this assignment silently
|
|
37
|
+
* regressed the chat thread (see `echoAfter` for the failure it caused).
|
|
35
38
|
*/
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
function assign(
|
|
40
|
+
instance: ReturnType<typeof execution.getInstance> & object,
|
|
41
|
+
state: ForkDecisionStepState,
|
|
42
|
+
): void {
|
|
40
43
|
const isLive = (s: (typeof instance.steps)[number]) =>
|
|
41
44
|
s.agentKind === 'coder' &&
|
|
42
45
|
(s.forkDecision?.status === 'awaiting_choice' ||
|
|
@@ -54,8 +57,13 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
|
|
|
54
57
|
async function load(executionId: string): Promise<void> {
|
|
55
58
|
error.value = null
|
|
56
59
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
await execution.echoAfter(
|
|
61
|
+
executionId,
|
|
62
|
+
() => api.getForkDecision(workspace.requireId(), executionId),
|
|
63
|
+
(state, instance) => {
|
|
64
|
+
if (state) assign(instance, state as ForkDecisionStepState)
|
|
65
|
+
},
|
|
66
|
+
)
|
|
59
67
|
} catch (e) {
|
|
60
68
|
error.value = e instanceof Error ? e.message : 'Failed to load'
|
|
61
69
|
}
|
|
@@ -72,8 +80,11 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
|
|
|
72
80
|
error.value = null
|
|
73
81
|
choosing.value = true
|
|
74
82
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
await execution.echoAfter(
|
|
84
|
+
executionId,
|
|
85
|
+
() => api.chooseFork(workspace.requireId(), executionId, choice),
|
|
86
|
+
(state, instance) => assign(instance, state as ForkDecisionStepState),
|
|
87
|
+
)
|
|
77
88
|
} catch (e) {
|
|
78
89
|
error.value = e instanceof Error ? e.message : 'Failed to choose'
|
|
79
90
|
throw e
|
|
@@ -85,15 +96,24 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
|
|
|
85
96
|
/**
|
|
86
97
|
* Send a grounded chat message about the surfaced forks. The reply is computed inline in the
|
|
87
98
|
* durable driver and arrives via the execution stream; the immediate response is the
|
|
88
|
-
* `answering` state (the human message already appended),
|
|
89
|
-
*
|
|
99
|
+
* `answering` state (the human message already appended), echoed so the thread shows the sent
|
|
100
|
+
* turn + a "thinking…" bubble without waiting for the stream.
|
|
101
|
+
*
|
|
102
|
+
* The echo is the RACIEST one in the app and must stay guarded: `chat` emits the one-message
|
|
103
|
+
* `answering` state and then wakes the driver, which appends the reply and emits again, so with a
|
|
104
|
+
* canned (no-model) reply the two-message thread frequently reaches the browser first. Applying
|
|
105
|
+
* this response unconditionally dropped the reply and left the bubble spinning forever, since a
|
|
106
|
+
* parked run emits nothing more.
|
|
90
107
|
*/
|
|
91
108
|
async function chat(executionId: string, text: string): Promise<void> {
|
|
92
109
|
error.value = null
|
|
93
110
|
chatting.value = true
|
|
94
111
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
await execution.echoAfter(
|
|
113
|
+
executionId,
|
|
114
|
+
() => api.forkChat(workspace.requireId(), executionId, text),
|
|
115
|
+
(state, instance) => assign(instance, state as ForkDecisionStepState),
|
|
116
|
+
)
|
|
97
117
|
} catch (e) {
|
|
98
118
|
error.value = e instanceof Error ? e.message : 'Failed to send message'
|
|
99
119
|
throw e
|
package/app/stores/judge.ts
CHANGED
|
@@ -27,13 +27,16 @@ export const useJudgeStore = defineStore('judge', () => {
|
|
|
27
27
|
* Reflect an authoritative judge state onto the run's judge step. A pipeline may place more
|
|
28
28
|
* than one judge, so target the step this verdict is about rather than the first one holding
|
|
29
29
|
* judge state: prefer the step still awaiting a decision, then the current step, and only then
|
|
30
|
-
* fall back to the first step carrying judge state.
|
|
31
|
-
*
|
|
30
|
+
* fall back to the first step carrying judge state.
|
|
31
|
+
*
|
|
32
|
+
* Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
|
|
33
|
+
* stream already delivered a newer revision — a `bounce` re-arms the producing step, so the
|
|
34
|
+
* driver is emitting fresh state while this response is still in flight.
|
|
32
35
|
*/
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
function assign(
|
|
37
|
+
instance: ReturnType<typeof execution.getInstance> & object,
|
|
38
|
+
state: JudgeStepState,
|
|
39
|
+
): void {
|
|
37
40
|
const current = instance.steps[instance.currentStep]
|
|
38
41
|
const step =
|
|
39
42
|
instance.steps.find((s) => s.judge?.status === 'awaiting_decision') ??
|
|
@@ -46,8 +49,13 @@ export const useJudgeStore = defineStore('judge', () => {
|
|
|
46
49
|
async function load(executionId: string): Promise<void> {
|
|
47
50
|
error.value = null
|
|
48
51
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
await execution.echoAfter(
|
|
53
|
+
executionId,
|
|
54
|
+
() => api.getJudgeState(workspace.requireId(), executionId),
|
|
55
|
+
(state, instance) => {
|
|
56
|
+
if (state) assign(instance, state as JudgeStepState)
|
|
57
|
+
},
|
|
58
|
+
)
|
|
51
59
|
} catch (e) {
|
|
52
60
|
error.value = e instanceof Error ? e.message : 'Failed to load'
|
|
53
61
|
}
|
|
@@ -66,11 +74,15 @@ export const useJudgeStore = defineStore('judge', () => {
|
|
|
66
74
|
error.value = null
|
|
67
75
|
resolving.value = true
|
|
68
76
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
await execution.echoAfter(
|
|
78
|
+
executionId,
|
|
79
|
+
() =>
|
|
80
|
+
api.resolveJudge(workspace.requireId(), executionId, {
|
|
81
|
+
choice,
|
|
82
|
+
...(feedback ? { feedback } : {}),
|
|
83
|
+
}),
|
|
84
|
+
(state, instance) => assign(instance, state as JudgeStepState),
|
|
85
|
+
)
|
|
74
86
|
} catch (e) {
|
|
75
87
|
error.value = e instanceof Error ? e.message : 'Failed to resolve'
|
|
76
88
|
throw e
|
package/app/stores/prReview.ts
CHANGED
|
@@ -20,20 +20,30 @@ export const usePrReviewStore = defineStore('prReview', () => {
|
|
|
20
20
|
|
|
21
21
|
/** True while a resolve call is in flight (drives the Finish button spinner / disabled state). */
|
|
22
22
|
const resolving = ref(false)
|
|
23
|
+
/**
|
|
24
|
+
* True while a RESUME call is in flight. Kept separate from `resolving` rather than folded into
|
|
25
|
+
* it: a resume acts during the `reviewing` phase and a resolve during `awaiting_selection`, so
|
|
26
|
+
* sharing one flag would let either action's spinner appear on the other's controls.
|
|
27
|
+
*/
|
|
28
|
+
const resuming = ref(false)
|
|
23
29
|
/** The last error message from an action, surfaced inline; cleared on the next action. */
|
|
24
30
|
const error = ref<string | null>(null)
|
|
25
31
|
|
|
26
32
|
/**
|
|
27
|
-
*
|
|
33
|
+
* Apply an authoritative PR-review state to the run's `pr-reviewer` step. A pipeline could
|
|
28
34
|
* carry more than one such step, so target the step this review is about: prefer the step that
|
|
29
35
|
* is still awaiting a selection, then the current step, and only then the first step carrying
|
|
30
|
-
* review state.
|
|
31
|
-
*
|
|
36
|
+
* review state.
|
|
37
|
+
*
|
|
38
|
+
* Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
|
|
39
|
+
* stream already delivered a newer revision. `resume` needs that guard most: it returns a
|
|
40
|
+
* `reviewing` state and then the re-dispatched reviewer starts publishing slice reviews, so an
|
|
41
|
+
* unguarded echo could put the freshly-captured reports back to what the resume saw.
|
|
32
42
|
*/
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
function assign(
|
|
44
|
+
instance: ReturnType<typeof execution.getInstance> & object,
|
|
45
|
+
state: PrReviewStepState,
|
|
46
|
+
): void {
|
|
37
47
|
const isLive = (s: (typeof instance.steps)[number]) =>
|
|
38
48
|
s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection'
|
|
39
49
|
const current = instance.steps[instance.currentStep]
|
|
@@ -48,8 +58,13 @@ export const usePrReviewStore = defineStore('prReview', () => {
|
|
|
48
58
|
async function load(executionId: string): Promise<void> {
|
|
49
59
|
error.value = null
|
|
50
60
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
await execution.echoAfter(
|
|
62
|
+
executionId,
|
|
63
|
+
() => api.getPrReview(workspace.requireId(), executionId),
|
|
64
|
+
(state, instance) => {
|
|
65
|
+
if (state) assign(instance, state as PrReviewStepState)
|
|
66
|
+
},
|
|
67
|
+
)
|
|
53
68
|
} catch (e) {
|
|
54
69
|
error.value = e instanceof Error ? e.message : 'Failed to load'
|
|
55
70
|
}
|
|
@@ -69,11 +84,11 @@ export const usePrReviewStore = defineStore('prReview', () => {
|
|
|
69
84
|
error.value = null
|
|
70
85
|
resolving.value = true
|
|
71
86
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
findingIds,
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
await execution.echoAfter(
|
|
88
|
+
executionId,
|
|
89
|
+
() => api.resolvePrReview(workspace.requireId(), executionId, { action, findingIds }),
|
|
90
|
+
(state, instance) => assign(instance, state as PrReviewStepState),
|
|
91
|
+
)
|
|
77
92
|
} catch (e) {
|
|
78
93
|
error.value = e instanceof Error ? e.message : 'Failed to resolve review'
|
|
79
94
|
throw e
|
|
@@ -82,13 +97,38 @@ export const usePrReviewStore = defineStore('prReview', () => {
|
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Resume a review stuck mid-`reviewing`: the reviewer is re-dispatched for only the slices that
|
|
102
|
+
* never reported, and the already-captured reports are fed back in so the finished slices are
|
|
103
|
+
* re-aggregated rather than re-reviewed. Rejected (409) unless the review is still `reviewing`.
|
|
104
|
+
*/
|
|
105
|
+
async function resume(executionId: string): Promise<void> {
|
|
106
|
+
error.value = null
|
|
107
|
+
resuming.value = true
|
|
108
|
+
try {
|
|
109
|
+
await execution.echoAfter(
|
|
110
|
+
executionId,
|
|
111
|
+
() => api.resumePrReview(workspace.requireId(), executionId),
|
|
112
|
+
(state, instance) => assign(instance, state as PrReviewStepState),
|
|
113
|
+
)
|
|
114
|
+
} catch (e) {
|
|
115
|
+
error.value = e instanceof Error ? e.message : 'Failed to resume review'
|
|
116
|
+
throw e
|
|
117
|
+
} finally {
|
|
118
|
+
resuming.value = false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
85
122
|
/** Dismiss a finding entirely: it's removed from the review (and the selection). Stays parked. */
|
|
86
123
|
async function dismiss(executionId: string, findingId: string): Promise<void> {
|
|
87
124
|
error.value = null
|
|
88
125
|
resolving.value = true
|
|
89
126
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
127
|
+
await execution.echoAfter(
|
|
128
|
+
executionId,
|
|
129
|
+
() => api.dismissPrReviewFinding(workspace.requireId(), executionId, findingId),
|
|
130
|
+
(state, instance) => assign(instance, state as PrReviewStepState),
|
|
131
|
+
)
|
|
92
132
|
} catch (e) {
|
|
93
133
|
error.value = e instanceof Error ? e.message : 'Failed to dismiss finding'
|
|
94
134
|
throw e
|
|
@@ -110,15 +150,14 @@ export const usePrReviewStore = defineStore('prReview', () => {
|
|
|
110
150
|
error.value = null
|
|
111
151
|
resolving.value = true
|
|
112
152
|
try {
|
|
113
|
-
|
|
114
|
-
workspace.requireId(),
|
|
153
|
+
await execution.echoAfter(
|
|
115
154
|
executionId,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
155
|
+
() =>
|
|
156
|
+
api.challengePrReviewFinding(workspace.requireId(), executionId, findingId, {
|
|
157
|
+
question,
|
|
158
|
+
}),
|
|
159
|
+
(state, instance) => assign(instance, state as PrReviewStepState),
|
|
120
160
|
)
|
|
121
|
-
reflect(executionId, state as PrReviewStepState)
|
|
122
161
|
} catch (e) {
|
|
123
162
|
error.value = e instanceof Error ? e.message : 'Failed to challenge finding'
|
|
124
163
|
throw e
|
|
@@ -127,5 +166,5 @@ export const usePrReviewStore = defineStore('prReview', () => {
|
|
|
127
166
|
}
|
|
128
167
|
}
|
|
129
168
|
|
|
130
|
-
return { resolving, error, load, resolve, dismiss, challenge }
|
|
169
|
+
return { resolving, resuming, error, load, resolve, resume, dismiss, challenge }
|
|
131
170
|
})
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
pipelineAllowedForBlockLevel,
|
|
4
|
+
pipelineAllowedForTaskType,
|
|
5
|
+
purposeAllowsAgentCategory,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
3
7
|
import type { Block, Pipeline } from '~/types/domain'
|
|
4
8
|
import {
|
|
5
9
|
pipelineAllowedForManualStart,
|
|
10
|
+
pipelineAllowedForSchedule,
|
|
6
11
|
pipelineDisplaySteps,
|
|
7
12
|
pipelineGateCount,
|
|
8
13
|
} from '~/utils/pipeline'
|
|
@@ -65,13 +70,67 @@ describe('pipelineAllowedForTaskType', () => {
|
|
|
65
70
|
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
|
|
66
71
|
})
|
|
67
72
|
|
|
68
|
-
it('
|
|
69
|
-
|
|
73
|
+
it('a programmatic task (feature / bug) hides only what cannot ship code', () => {
|
|
74
|
+
// These ship code, so a doc-authoring or PR-review preset is meaningless for them — the mirror
|
|
75
|
+
// of the narrowing document/review tasks already had. `research` stays because reaching for a
|
|
76
|
+
// spike before committing to an approach is legitimate on an unscoped feature.
|
|
77
|
+
for (const type of ['feature', 'bug'] as const) {
|
|
70
78
|
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), type)).toBe(true)
|
|
71
|
-
expect(pipelineAllowedForTaskType(pipeline({ purpose: '
|
|
72
|
-
expect(pipelineAllowedForTaskType(pipeline({ purpose: '
|
|
79
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'research' }), type)).toBe(true)
|
|
80
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), type)).toBe(false)
|
|
81
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'review' }), type)).toBe(false)
|
|
82
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'planning' }), type)).toBe(false)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('keeps an UNCLASSIFIED pipeline on a feature / bug task', () => {
|
|
87
|
+
// The one place this narrowing runs opposite to the document/review one, and it has to: a
|
|
88
|
+
// `purpose` is optional at every write boundary (the builder leaves it unset by default, a
|
|
89
|
+
// registered deployment pipeline need not declare one), so requiring it here would hide a
|
|
90
|
+
// workspace's own hand-built pipelines from the picker they were built for — silently, with
|
|
91
|
+
// nothing on screen to explain the absence. Unclassified is not known-wrong for a feature the
|
|
92
|
+
// way a document preset is.
|
|
93
|
+
for (const type of ['feature', 'bug'] as const) {
|
|
73
94
|
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), type)).toBe(true)
|
|
74
95
|
}
|
|
96
|
+
// Still hidden from the types whose narrowing DOES demand the explicit classifier.
|
|
97
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'document')).toBe(false)
|
|
98
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('an un-narrowed task type stays unrestricted (spike, ralph, custom, undefined)', () => {
|
|
102
|
+
// A custom (namespaced) deployment type has no purpose mapping we could infer, and `spike` /
|
|
103
|
+
// `ralph` pin their own default pipeline instead of narrowing the picker.
|
|
104
|
+
for (const type of ['spike', 'ralph', 'acme:incident', undefined] as const) {
|
|
105
|
+
for (const purpose of ['build', 'document', 'review', 'research', undefined] as const) {
|
|
106
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose }), type)).toBe(true)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('pipelineAllowedForBlockLevel (initiative binding)', () => {
|
|
113
|
+
it('offers an initiative block only planning pipelines', () => {
|
|
114
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'planning' }), 'initiative')).toBe(true)
|
|
115
|
+
for (const purpose of ['build', 'document', 'review', 'research', undefined] as const) {
|
|
116
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose }), 'initiative')).toBe(false)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('hides planning pipelines from every ordinary block level', () => {
|
|
121
|
+
// The surface half of the engine's BIDIRECTIONAL guard. Without it the planning presets were
|
|
122
|
+
// offered on ordinary tasks and then refused at start with a 409 — the user having already
|
|
123
|
+
// chosen before learning it could not run.
|
|
124
|
+
for (const level of ['task', 'frame', 'module', 'epic'] as const) {
|
|
125
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'planning' }), level)).toBe(false)
|
|
126
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'build' }), level)).toBe(true)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('is unrestricted when the level is unknown', () => {
|
|
131
|
+
for (const purpose of ['build', 'planning', undefined] as const) {
|
|
132
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose }), undefined)).toBe(true)
|
|
133
|
+
}
|
|
75
134
|
})
|
|
76
135
|
})
|
|
77
136
|
|
|
@@ -118,3 +177,24 @@ describe('pipelineAllowedForManualStart composes the task-type gate', () => {
|
|
|
118
177
|
expect(pipelineAllowedForManualStart(recurring, noFrame, blocks, 'document')).toBe(false)
|
|
119
178
|
})
|
|
120
179
|
})
|
|
180
|
+
|
|
181
|
+
describe('pipelineAllowedForSchedule', () => {
|
|
182
|
+
const noFrame = undefined
|
|
183
|
+
const blocks: Block[] = []
|
|
184
|
+
|
|
185
|
+
it('keeps an ordinary build pipeline and drops a one-off-only one', () => {
|
|
186
|
+
expect(pipelineAllowedForSchedule(pipeline({ purpose: 'build' }), noFrame, blocks)).toBe(true)
|
|
187
|
+
const oneOff = pipeline({ purpose: 'build', availability: 'one-off' })
|
|
188
|
+
expect(pipelineAllowedForSchedule(oneOff, noFrame, blocks)).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('drops the planning presets, which nothing else keeps out of this picker', () => {
|
|
192
|
+
// A schedule seeds a `level: 'task'` block on every fire, so the engine refuses a planning
|
|
193
|
+
// pipeline exactly as it would on a manual start — and the planning presets carry no
|
|
194
|
+
// `availability`, so the one-off filter above never touched them. Worse than the manual case
|
|
195
|
+
// because a schedule fires unattended: nobody sees the refusal, the work just stops happening.
|
|
196
|
+
expect(pipelineAllowedForSchedule(pipeline({ purpose: 'planning' }), noFrame, blocks)).toBe(
|
|
197
|
+
false,
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
})
|
package/app/utils/pipeline.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
frameAllowsVisualPipeline,
|
|
3
|
+
pipelineAllowedForBlockLevel,
|
|
3
4
|
pipelineAllowedForTaskType,
|
|
4
5
|
pipelineHasVisualStep,
|
|
5
6
|
} from '@cat-factory/contracts'
|
|
6
|
-
import type { AgentKind, Block, Pipeline } from '~/types/domain'
|
|
7
|
+
import type { AgentKind, Block, BlockLevel, Pipeline } from '~/types/domain'
|
|
7
8
|
|
|
8
9
|
/** One agent step of a pipeline as shown in a preview: its kind + whether it's a human-gated step. */
|
|
9
10
|
export interface PipelineDisplayStep {
|
|
@@ -38,9 +39,9 @@ export function pipelineGateCount(pipeline: Pipeline): number {
|
|
|
38
39
|
return pipelineDisplaySteps(pipeline).filter((s) => s.gated).length
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
// Re-exported so a picker can import the
|
|
42
|
-
//
|
|
43
|
-
export { pipelineAllowedForTaskType }
|
|
42
|
+
// Re-exported so a picker can import the purpose gates from the same module as the launch/frame
|
|
43
|
+
// gates they compose with (the classifiers themselves live in `@cat-factory/contracts`).
|
|
44
|
+
export { pipelineAllowedForBlockLevel, pipelineAllowedForTaskType }
|
|
44
45
|
|
|
45
46
|
// Surface counterpart to the backend's slice-4c run-start gate: a pipeline with a visual step
|
|
46
47
|
// (`tester-ui` / `visual-confirmation`) may run only on a frame with a UI to exercise — a
|
|
@@ -69,32 +70,52 @@ export function pipelineAllowedForFrame(
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Whether `pipeline` may be started as a MANUAL one-off task run (the board/inspector Run menus,
|
|
72
|
-
* the add-task modal, the task run-settings default). Excludes
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* pipelines
|
|
73
|
+
* the add-task modal, the task run-settings default). Excludes, in turn:
|
|
74
|
+
*
|
|
75
|
+
* - `'recurring'`-only pipelines the backend would refuse;
|
|
76
|
+
* - visual pipelines on a frame with no UI;
|
|
77
|
+
* - pipelines whose `purpose` doesn't fit the given `taskType` (a `document` task offers only
|
|
78
|
+
* document pipelines; a `feature`/`bug` task only build + research ones);
|
|
79
|
+
* - pipelines whose `purpose` doesn't fit the given `blockLevel` (planning pipelines run only on
|
|
80
|
+
* an initiative block, and an initiative block runs only those).
|
|
81
|
+
*
|
|
82
|
+
* `taskType` / `blockLevel` omitted ⇒ that restriction is not applied, so an un-typed context still
|
|
83
|
+
* shows everything.
|
|
76
84
|
*/
|
|
77
85
|
export function pipelineAllowedForManualStart(
|
|
78
86
|
pipeline: Pipeline,
|
|
79
87
|
frame: Block | undefined,
|
|
80
88
|
blocks: readonly Block[],
|
|
81
89
|
taskType?: Block['taskType'],
|
|
90
|
+
blockLevel?: BlockLevel,
|
|
82
91
|
): boolean {
|
|
83
92
|
return (
|
|
84
93
|
pipeline.availability !== 'recurring' &&
|
|
85
94
|
pipelineAllowedForFrame(pipeline, frame, blocks) &&
|
|
86
|
-
pipelineAllowedForTaskType(pipeline, taskType)
|
|
95
|
+
pipelineAllowedForTaskType(pipeline, taskType) &&
|
|
96
|
+
pipelineAllowedForBlockLevel(pipeline, blockLevel)
|
|
87
97
|
)
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
/**
|
|
91
101
|
* Whether `pipeline` may be attached to a RECURRING schedule (the recurring-pipeline modal).
|
|
92
|
-
* Excludes `'one-off'`-only pipelines the backend would refuse
|
|
102
|
+
* Excludes `'one-off'`-only pipelines the backend would refuse, visual pipelines on a frame with no
|
|
103
|
+
* UI, and the planning presets.
|
|
104
|
+
*
|
|
105
|
+
* The block-level gate applies here for the same reason it applies to a manual start, and it is
|
|
106
|
+
* keyed to `'task'` because a schedule seeds a `level: 'task'` block under its frame on every fire
|
|
107
|
+
* (`RecurringPipelineService`). The planning presets carry no `availability`, so nothing else keeps
|
|
108
|
+
* them out of this picker — and a schedule the engine refuses is WORSE than a manual start it
|
|
109
|
+
* refuses: it fires unattended, so nobody sees the error and the work simply never happens.
|
|
93
110
|
*/
|
|
94
111
|
export function pipelineAllowedForSchedule(
|
|
95
112
|
pipeline: Pipeline,
|
|
96
113
|
frame: Block | undefined,
|
|
97
114
|
blocks: readonly Block[],
|
|
98
115
|
): boolean {
|
|
99
|
-
return
|
|
116
|
+
return (
|
|
117
|
+
pipeline.availability !== 'one-off' &&
|
|
118
|
+
pipelineAllowedForFrame(pipeline, frame, blocks) &&
|
|
119
|
+
pipelineAllowedForBlockLevel(pipeline, 'task')
|
|
120
|
+
)
|
|
100
121
|
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -5660,6 +5660,10 @@
|
|
|
5660
5660
|
"pending": "In Warteschlange"
|
|
5661
5661
|
}
|
|
5662
5662
|
},
|
|
5663
|
+
"resume": {
|
|
5664
|
+
"action": "Review fortsetzen",
|
|
5665
|
+
"hint": "Hängt es? Beim Fortsetzen werden nur die Abschnitte erneut geprüft, die nie zurückgemeldet haben; die Ergebnisse der fertigen bleiben erhalten."
|
|
5666
|
+
},
|
|
5663
5667
|
"phase": {
|
|
5664
5668
|
"planning": "Wird geprüft…",
|
|
5665
5669
|
"reviewing": "Prüfe {completed}/{total} Abschnitte",
|
package/i18n/locales/en.json
CHANGED
|
@@ -5836,6 +5836,10 @@
|
|
|
5836
5836
|
"pending": "Queued"
|
|
5837
5837
|
}
|
|
5838
5838
|
},
|
|
5839
|
+
"resume": {
|
|
5840
|
+
"action": "Resume review",
|
|
5841
|
+
"hint": "Looks stuck? Resuming re-reviews only the chunks that never came back, and keeps the findings from the ones that did."
|
|
5842
|
+
},
|
|
5839
5843
|
"phase": {
|
|
5840
5844
|
"planning": "Reviewing…",
|
|
5841
5845
|
"reviewing": "Reviewing {completed}/{total} slices",
|
package/i18n/locales/es.json
CHANGED
|
@@ -5648,6 +5648,10 @@
|
|
|
5648
5648
|
"pending": "En cola"
|
|
5649
5649
|
}
|
|
5650
5650
|
},
|
|
5651
|
+
"resume": {
|
|
5652
|
+
"action": "Reanudar la revisión",
|
|
5653
|
+
"hint": "¿Parece atascada? Al reanudar solo se vuelven a revisar los fragmentos que nunca respondieron y se conservan los hallazgos de los ya terminados."
|
|
5654
|
+
},
|
|
5651
5655
|
"phase": {
|
|
5652
5656
|
"planning": "Revisando…",
|
|
5653
5657
|
"reviewing": "Revisando {completed}/{total} secciones",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -5648,6 +5648,10 @@
|
|
|
5648
5648
|
"pending": "En attente"
|
|
5649
5649
|
}
|
|
5650
5650
|
},
|
|
5651
|
+
"resume": {
|
|
5652
|
+
"action": "Reprendre la revue",
|
|
5653
|
+
"hint": "Elle semble bloquée ? La reprise ne réexamine que les sections qui n’ont jamais répondu et conserve les constats de celles déjà terminées."
|
|
5654
|
+
},
|
|
5651
5655
|
"phase": {
|
|
5652
5656
|
"planning": "Révision…",
|
|
5653
5657
|
"reviewing": "Révision {completed}/{total} sections",
|
package/i18n/locales/he.json
CHANGED
|
@@ -5659,6 +5659,10 @@
|
|
|
5659
5659
|
"pending": "בתור"
|
|
5660
5660
|
}
|
|
5661
5661
|
},
|
|
5662
|
+
"resume": {
|
|
5663
|
+
"action": "המשך סקירה",
|
|
5664
|
+
"hint": "נראה שנתקע? המשך יבדוק מחדש רק את המקטעים שלא חזרו, וישמור את הממצאים של אלה שכבר הושלמו."
|
|
5665
|
+
},
|
|
5662
5666
|
"phase": {
|
|
5663
5667
|
"planning": "בודק…",
|
|
5664
5668
|
"reviewing": "בודק {completed}/{total} מקטעים",
|
package/i18n/locales/it.json
CHANGED
|
@@ -5660,6 +5660,10 @@
|
|
|
5660
5660
|
"pending": "In coda"
|
|
5661
5661
|
}
|
|
5662
5662
|
},
|
|
5663
|
+
"resume": {
|
|
5664
|
+
"action": "Riprendi la revisione",
|
|
5665
|
+
"hint": "Sembra bloccata? La ripresa riesamina solo i blocchi che non hanno mai risposto e conserva i rilievi di quelli già completati."
|
|
5666
|
+
},
|
|
5663
5667
|
"phase": {
|
|
5664
5668
|
"planning": "Revisione…",
|
|
5665
5669
|
"reviewing": "Revisione {completed}/{total} sezioni",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -5660,6 +5660,10 @@
|
|
|
5660
5660
|
"pending": "待機中"
|
|
5661
5661
|
}
|
|
5662
5662
|
},
|
|
5663
|
+
"resume": {
|
|
5664
|
+
"action": "レビューを再開",
|
|
5665
|
+
"hint": "停止しているように見えますか?再開すると、応答がなかったチャンクだけを再レビューし、完了済みチャンクの指摘はそのまま引き継ぎます。"
|
|
5666
|
+
},
|
|
5663
5667
|
"phase": {
|
|
5664
5668
|
"planning": "レビュー中…",
|
|
5665
5669
|
"reviewing": "レビュー中 {completed}/{total} 区分",
|
package/i18n/locales/pl.json
CHANGED
|
@@ -5648,6 +5648,10 @@
|
|
|
5648
5648
|
"pending": "W kolejce"
|
|
5649
5649
|
}
|
|
5650
5650
|
},
|
|
5651
|
+
"resume": {
|
|
5652
|
+
"action": "Wznów przegląd",
|
|
5653
|
+
"hint": "Wygląda na zawieszony? Wznowienie sprawdza ponownie tylko te fragmenty, które nigdy nie odpowiedziały, i zachowuje ustalenia z już ukończonych."
|
|
5654
|
+
},
|
|
5651
5655
|
"phase": {
|
|
5652
5656
|
"planning": "Przegląd…",
|
|
5653
5657
|
"reviewing": "Przegląd {completed}/{total} fragmentów",
|
package/i18n/locales/tr.json
CHANGED
|
@@ -5660,6 +5660,10 @@
|
|
|
5660
5660
|
"pending": "Sırada"
|
|
5661
5661
|
}
|
|
5662
5662
|
},
|
|
5663
|
+
"resume": {
|
|
5664
|
+
"action": "İncelemeyi sürdür",
|
|
5665
|
+
"hint": "Takılmış gibi mi görünüyor? Sürdürme yalnızca hiç yanıt vermeyen parçaları yeniden inceler ve tamamlananların bulgularını korur."
|
|
5666
|
+
},
|
|
5663
5667
|
"phase": {
|
|
5664
5668
|
"planning": "İnceleniyor…",
|
|
5665
5669
|
"reviewing": "İnceleniyor {completed}/{total} bölüm",
|
package/i18n/locales/uk.json
CHANGED
|
@@ -5648,6 +5648,10 @@
|
|
|
5648
5648
|
"pending": "У черзі"
|
|
5649
5649
|
}
|
|
5650
5650
|
},
|
|
5651
|
+
"resume": {
|
|
5652
|
+
"action": "Відновити рецензування",
|
|
5653
|
+
"hint": "Схоже, що зависло? Відновлення повторно перевіряє лише ті фрагменти, які не відповіли, і зберігає висновки вже завершених."
|
|
5654
|
+
},
|
|
5651
5655
|
"phase": {
|
|
5652
5656
|
"planning": "Перевірка…",
|
|
5653
5657
|
"reviewing": "Перевірка {completed}/{total} фрагментів",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.193.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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"valibot": "^1.4.2",
|
|
41
41
|
"vue": "3.5.40",
|
|
42
42
|
"wretch": "^3.0.9",
|
|
43
|
-
"@cat-factory/contracts": "0.
|
|
43
|
+
"@cat-factory/contracts": "0.200.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|