@cat-factory/app 0.215.2 → 0.216.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/README.md +109 -1
- package/app/components/inputGate/InputGateNotice.vue +176 -0
- package/app/components/panels/AgentStepDetail.vue +27 -3
- package/app/components/panels/inspector/TaskExecution.vue +32 -3
- package/app/components/pipeline/PipelineProgress.vue +1 -1
- package/app/components/settings/WorkspaceSettingsPanel.vue +34 -1
- package/app/composables/api/inputGate.ts +25 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/stores/inputGate.ts +58 -0
- package/app/stores/ui/resultViews.ts +9 -1
- package/app/stores/workspaceSettings.ts +1 -0
- package/app/types/domain.ts +1 -0
- package/app/utils/inputGate.spec.ts +52 -0
- package/app/utils/inputGate.ts +44 -0
- package/app/utils/pipelineRender.spec.ts +47 -9
- package/app/utils/pipelineRender.ts +23 -2
- package/i18n/locales/de.json +59 -0
- package/i18n/locales/en.json +59 -0
- package/i18n/locales/es.json +59 -0
- package/i18n/locales/fr.json +59 -0
- package/i18n/locales/he.json +59 -0
- package/i18n/locales/it.json +59 -0
- package/i18n/locales/ja.json +59 -0
- package/i18n/locales/pl.json +59 -0
- package/i18n/locales/tr.json +59 -0
- package/i18n/locales/uk.json +59 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,6 +18,9 @@ The SPA source lives under `app/` (the Nuxt srcDir).
|
|
|
18
18
|
- [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
|
|
19
19
|
- [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
|
|
20
20
|
- [In-app tutorial tours](#in-app-tutorial-tours)
|
|
21
|
+
- [Real-time store coherence](#real-time-store-coherence-avoid-the-full-refresh-clobber)
|
|
22
|
+
- [Internationalization (i18n) authoring](#internationalization-i18n-authoring)
|
|
23
|
+
- [Extending the layer (consumer modules)](#extending-the-layer-consumer-modules)
|
|
21
24
|
- [Key UI surfaces](#key-ui-surfaces)
|
|
22
25
|
- [Develop & test](#develop--test)
|
|
23
26
|
|
|
@@ -329,7 +332,7 @@ is SUPPRESSED rather than unmounted, because it holds the running tour's resolve
|
|
|
329
332
|
remount would re-resolve it against gates that may have flipped since the tour started.
|
|
330
333
|
|
|
331
334
|
The decisions behind this surface, and why each alternative was rejected, are recorded in
|
|
332
|
-
[ADR
|
|
335
|
+
[ADR 0036](../../backend/docs/adr/0036-in-app-tutorials.md). This section is the authority on how
|
|
333
336
|
the thing WORKS.
|
|
334
337
|
|
|
335
338
|
A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
|
|
@@ -511,6 +514,111 @@ guard scans the layer for both ways an id is named: written onto an element, or
|
|
|
511
514
|
`testId` field on a data contribution (the whole `nav-*` family reaches the DOM that way). It
|
|
512
515
|
is scoped to the built-in catalog, since a consumer's tours anchor on its own layer.
|
|
513
516
|
|
|
517
|
+
## Real-time store coherence: avoid the full-refresh CLOBBER
|
|
518
|
+
|
|
519
|
+
The recurring product bug behind most e2e flakes: a stale full-snapshot refresh clobbering newer
|
|
520
|
+
live state. The SPA has two delivery shapes and mixing them wrong drops live-added state with NO
|
|
521
|
+
event left to restore it.
|
|
522
|
+
|
|
523
|
+
- **Know how your entity is delivered.** A `board` event is COARSE: no payload, only a debounced
|
|
524
|
+
full `workspace.refresh()`, and `hydrate` REPLACES whole lists. A spawned task/module block
|
|
525
|
+
reaches the browser ONLY this way. Targeted events (`execution`/`bootstrap`/`initiative`) carry
|
|
526
|
+
the entity and `upsert` it, so they don't clobber. Prefer a targeted upsert for anything that
|
|
527
|
+
must appear reliably.
|
|
528
|
+
- **Full refreshes MUST be monotonic.** Two `refresh()` calls can be in flight; a staler one
|
|
529
|
+
resolving later overwrites the newer. `workspace.refresh()` guards this with a sequence. Do not
|
|
530
|
+
reintroduce an unguarded `hydrate(await fetch())`, and apply the guard to any new coalesced
|
|
531
|
+
refresh path.
|
|
532
|
+
- **Never gate readiness on a snapshot a later resync can undo.** The on-connect resync flips
|
|
533
|
+
`connected` only after it settles (which is why e2e gates on `data-connected`).
|
|
534
|
+
- **A REPLACE-style `hydrate` must never silently drop live-only state.** Either fold that state
|
|
535
|
+
into the snapshot or reconcile rather than replace.
|
|
536
|
+
- **An action's OPTIMISTIC ECHO is a clobber too, and it bypasses both guards above.** A store
|
|
537
|
+
that awaits a mutation and then assigns the returned sub-state onto the cached run
|
|
538
|
+
(`step.forkDecision`, `step.prReview`, `step.judge`, `step.followUps`) is writing straight past
|
|
539
|
+
`upsert`'s `rev` check. Where the mutation WAKES THE DRIVER, the driver's next emit routinely
|
|
540
|
+
beats the HTTP response, so the echo puts the run back; if the run then parks, nothing emits
|
|
541
|
+
again and the newer state is gone for good (the fork-chat reply that vanished, leaving a
|
|
542
|
+
"thinking…" bubble spinning). Every echo therefore goes through
|
|
543
|
+
`execution.echoAfter(executionId, send, apply)`, which captures the run's `rev` before the
|
|
544
|
+
request and drops the echo if anything advanced it. Never hand-roll the await-then-assign.
|
|
545
|
+
- **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
|
|
546
|
+
`stores/execution.spec.ts` for echoes): drive the two orderings and assert the fresher one
|
|
547
|
+
wins.
|
|
548
|
+
|
|
549
|
+
## Internationalization (i18n) authoring
|
|
550
|
+
|
|
551
|
+
All user-facing SPA copy goes through `@nuxtjs/i18n`; never hard-code a display string. This
|
|
552
|
+
layer ships the base `en` locale, and a downstream deployment overrides by dropping its own files
|
|
553
|
+
(the per-layer deep-merge is the override seam, consumer wins key by key). Migration status:
|
|
554
|
+
[`docs/localization.md`](../../docs/localization.md).
|
|
555
|
+
|
|
556
|
+
- `i18n/locales/<locale>.json`: the catalogs (the v9+ `i18n/` convention, NOT `app/locales/`).
|
|
557
|
+
- `i18n/i18n.config.ts`: runtime vue-i18n behaviour only (fallback locale, the named
|
|
558
|
+
`numberFormats`/`datetimeFormats`). Messages are deliberately NOT here so the module can
|
|
559
|
+
deep-merge across the `extends` chain. Referenced as the BARE filename
|
|
560
|
+
`vueI18n: 'i18n.config.ts'`, never `layerDir`-anchored.
|
|
561
|
+
- `package.json` `files` MUST include `"i18n"`. Release-blocking.
|
|
562
|
+
|
|
563
|
+
**Adding a string**: add the key to `en.json` under the feature namespace, resolve with
|
|
564
|
+
`t('feature.area.key')`, and format numbers/dates through `$n`/`$d` (the named formats), never
|
|
565
|
+
raw `Intl`.
|
|
566
|
+
|
|
567
|
+
**Key conventions**: one namespace per feature; **leaf keys mirror the enum/code value verbatim**
|
|
568
|
+
so a dynamic lookup is total; **no cross-key concatenation** (a full sentence is ONE key with
|
|
569
|
+
`{named}` placeholders, plurals use the pipe form).
|
|
570
|
+
|
|
571
|
+
**Component mechanics that bite:**
|
|
572
|
+
|
|
573
|
+
- `useI18n` is auto-imported; destructure in `<script setup>` and use those fns in the template
|
|
574
|
+
so the typed-key check sees literal keys. Never `import` it.
|
|
575
|
+
- Plural + interpolation: `t(key, { vendor, count }, count)`, where the THIRD arg is the choice.
|
|
576
|
+
- **Code/format-example placeholders stay INLINE**, not in the catalog; required when they
|
|
577
|
+
contain `{`/`}` (vue-i18n metacharacters). Only prose placeholders get a key. Same for brand
|
|
578
|
+
names.
|
|
579
|
+
- **No HTML in message bodies**: drop mid-sentence `<strong>`, or use `<i18n-t>` with slots.
|
|
580
|
+
- For a vendor/enum-keyed set, build an array of STATIC literal `t()` keys, one per member.
|
|
581
|
+
Reserve the runtime-assembled key + exhaustive `Record` guard for lookups genuinely unknown
|
|
582
|
+
until runtime.
|
|
583
|
+
- Straight quotes, no em-dashes in new entries.
|
|
584
|
+
|
|
585
|
+
**Translator descriptions (`@<key>` siblings): default to NONE.** They live only in `en.json` and
|
|
586
|
+
are notes to a translator, never runtime data. Add one ONLY when a competent translator seeing
|
|
587
|
+
the English and the key path could plausibly get it wrong: homograph / part-of-speech ambiguity
|
|
588
|
+
(`@close`), proper nouns that must NOT be translated (`@kaizen`), umbrella strings hiding cases
|
|
589
|
+
the text doesn't show, placeholder/format constraints, or plural-form requirements beyond
|
|
590
|
+
English's two.
|
|
591
|
+
|
|
592
|
+
**Presenting a backend failure**: raw backend prose is DETAIL, never the description. Even with
|
|
593
|
+
no `reason` to key off, a failure is described from its STATUS CLASS through an exhaustive
|
|
594
|
+
`Record<ApiErrorCode, …>` of translated copy, and the untranslated `message` (plus a validation
|
|
595
|
+
400's `issues` and the envelope's `requestId`) is reached through a "Show details" disclosure
|
|
596
|
+
that reveals it in place. So a non-English user is never handed English as the primary
|
|
597
|
+
explanation, and the elaborate operator remedies the backend does write stay one click away
|
|
598
|
+
rather than being dropped. A new failure-presenting surface copies that split (the
|
|
599
|
+
`usePipelineErrorToast.ts` pattern; the wire vocabulary comes from `@cat-factory/contracts`).
|
|
600
|
+
|
|
601
|
+
**Drift guards** (oxlint has no `no-raw-text` rule, so these replace it):
|
|
602
|
+
|
|
603
|
+
1. **Typed message keys** make a statically written unknown `t('literal.key')` a typecheck
|
|
604
|
+
failure. This does NOT cover a runtime-assembled key.
|
|
605
|
+
2. For enum→key lookups, guard with an **exhaustive `Record<TheEnum, string>`** keyed off the
|
|
606
|
+
contracts union, plus a runtime `te()` fallback. Never rely on tier 1 alone for a
|
|
607
|
+
reason/status-keyed lookup.
|
|
608
|
+
3. `pnpm --filter @cat-factory/app run i18n:check` hard-fails on MISSING keys and reports unused
|
|
609
|
+
ones as non-blocking warnings (the catalog legitimately seeds keys ahead of use).
|
|
610
|
+
4. **Locale parity**: `i18n-locale-parity.mjs --since origin/<base>` requires a PR that adds,
|
|
611
|
+
changes, or removes an `en.json` key to make the SAME change in every other locale. It is
|
|
612
|
+
change-coupling against the merge-base, NOT full key parity.
|
|
613
|
+
|
|
614
|
+
**Translate for real: NEVER ship an English string as a non-`en` value.** The parity gate checks
|
|
615
|
+
only that the key exists, so it will pass a verbatim English copy, and that copy is a bug. The
|
|
616
|
+
only values that may legitimately match `en` are proper nouns identical across languages
|
|
617
|
+
(`DeepSeek`, `AWS Bedrock`). If you genuinely cannot produce a translation, say so in the PR
|
|
618
|
+
rather than committing a placeholder that reads as done.
|
|
619
|
+
|
|
620
|
+
Migration is incremental: when you touch a component, lift its visible copy into the catalog.
|
|
621
|
+
|
|
514
622
|
## Extending the layer (consumer modules)
|
|
515
623
|
|
|
516
624
|
A deployment can contribute its own components (result windows, nav entries, inspector
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { InputGateIssue, InputGateIssueCode, RunInputGate } from '@cat-factory/contracts'
|
|
4
|
+
import type { InputGateTone } from '~/utils/inputGate'
|
|
5
|
+
import { useInputGateStore } from '~/stores/inputGate'
|
|
6
|
+
|
|
7
|
+
// The PRE-TOKEN INPUT GATE's notice: what the structural check found in the task's authored
|
|
8
|
+
// input, and the two ways out. Shown wherever a run parked on the gate is surfaced (the
|
|
9
|
+
// inspector's execution panel, the step-detail overlay), so it is a plain component over a
|
|
10
|
+
// verdict rather than an overlay of its own, its remedy is to go and edit the task, which is a
|
|
11
|
+
// board action a modal would be in the way of.
|
|
12
|
+
//
|
|
13
|
+
// Every line of copy is keyed off the finding CODE, never off backend prose: the backend does
|
|
14
|
+
// not localize, and its `describeInputGateIssues` summary is a detail line for logs.
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
/** The run's verdict. Which verdicts earn a notice is `inputGateNoticeFor`'s decision. */
|
|
18
|
+
gate: RunInputGate
|
|
19
|
+
/**
|
|
20
|
+
* How to present it (see {@link InputGateTone}). Passed in rather than re-derived from
|
|
21
|
+
* `gate.status`, because the advisory tone is NOT a status: it is a `passed` verdict that
|
|
22
|
+
* happens to carry findings, and a component deriving its own tone would have to repeat that
|
|
23
|
+
* rule and would go on rendering advisories as if the run had been cleared with nothing found.
|
|
24
|
+
*/
|
|
25
|
+
tone: InputGateTone
|
|
26
|
+
/** The run this verdict belongs to, for the resolve calls. */
|
|
27
|
+
executionId: string
|
|
28
|
+
/** Compact form drops the explanatory paragraph (used inside the step-detail rail). */
|
|
29
|
+
compact?: boolean
|
|
30
|
+
}>()
|
|
31
|
+
|
|
32
|
+
const { t, te } = useI18n()
|
|
33
|
+
const inputGate = useInputGateStore()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Finding code → its translated copy, as an EXHAUSTIVE `Record` of LITERAL keys. Two guards in
|
|
37
|
+
* one: the Record fails to compile when a code is added without copy, and the literal keys are
|
|
38
|
+
* what the typed-message-key check can see (an assembled `\`inputGate.issue.${code}.title\`` is
|
|
39
|
+
* invisible to it). The `te` fallback below covers the case neither can, a run PERSISTED under
|
|
40
|
+
* a code this build has since retired.
|
|
41
|
+
*/
|
|
42
|
+
const ISSUE_KEYS = {
|
|
43
|
+
description_missing: {
|
|
44
|
+
title: 'inputGate.issue.description_missing.title',
|
|
45
|
+
hint: 'inputGate.issue.description_missing.hint',
|
|
46
|
+
},
|
|
47
|
+
description_placeholder: {
|
|
48
|
+
title: 'inputGate.issue.description_placeholder.title',
|
|
49
|
+
hint: 'inputGate.issue.description_placeholder.hint',
|
|
50
|
+
},
|
|
51
|
+
description_thin: {
|
|
52
|
+
title: 'inputGate.issue.description_thin.title',
|
|
53
|
+
hint: 'inputGate.issue.description_thin.hint',
|
|
54
|
+
},
|
|
55
|
+
reproduction_missing: {
|
|
56
|
+
title: 'inputGate.issue.reproduction_missing.title',
|
|
57
|
+
hint: 'inputGate.issue.reproduction_missing.hint',
|
|
58
|
+
},
|
|
59
|
+
review_target_missing: {
|
|
60
|
+
title: 'inputGate.issue.review_target_missing.title',
|
|
61
|
+
hint: 'inputGate.issue.review_target_missing.hint',
|
|
62
|
+
},
|
|
63
|
+
success_criteria_missing: {
|
|
64
|
+
title: 'inputGate.issue.success_criteria_missing.title',
|
|
65
|
+
hint: 'inputGate.issue.success_criteria_missing.hint',
|
|
66
|
+
},
|
|
67
|
+
} as const satisfies Record<InputGateIssueCode, { title: string; hint: string }>
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The findings, blocking first. Sorting here rather than trusting the emitted order keeps the
|
|
71
|
+
* thing a human must fix at the top even when an advisory was found earlier in the check.
|
|
72
|
+
*/
|
|
73
|
+
const issues = computed<InputGateIssue[]>(() =>
|
|
74
|
+
[...props.gate.issues].sort((a, b) =>
|
|
75
|
+
a.severity === b.severity ? 0 : a.severity === 'blocking' ? -1 : 1,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
/** Only a parked verdict has anything to answer; the other two tones are a record. */
|
|
80
|
+
const blocking = computed(() => props.tone === 'blocked')
|
|
81
|
+
|
|
82
|
+
/** Title + body keys per tone, as literals so the typed-message-key check can see them. */
|
|
83
|
+
const TONE_COPY: Record<InputGateTone, { title: string; body: string }> = {
|
|
84
|
+
blocked: { title: 'inputGate.blockedTitle', body: 'inputGate.blockedBody' },
|
|
85
|
+
waived: { title: 'inputGate.waivedTitle', body: 'inputGate.waivedBody' },
|
|
86
|
+
advisory: { title: 'inputGate.advisoryTitle', body: 'inputGate.advisoryBody' },
|
|
87
|
+
}
|
|
88
|
+
const copy = computed(() => TONE_COPY[props.tone])
|
|
89
|
+
|
|
90
|
+
/** A finding's translated title, falling back to the generic line for a retired code. */
|
|
91
|
+
function issueTitle(code: InputGateIssueCode): string {
|
|
92
|
+
const key = ISSUE_KEYS[code]?.title
|
|
93
|
+
return key && te(key) ? t(key) : t('inputGate.issue.unknown.title')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A finding's translated remedy hint, on the same fallback. */
|
|
97
|
+
function issueHint(code: InputGateIssueCode): string {
|
|
98
|
+
const key = ISSUE_KEYS[code]?.hint
|
|
99
|
+
return key && te(key) ? t(key) : t('inputGate.issue.unknown.hint')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function resolve(choice: 'recheck' | 'proceed') {
|
|
103
|
+
await inputGate.resolve(props.executionId, choice)
|
|
104
|
+
}
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<template>
|
|
108
|
+
<div
|
|
109
|
+
class="rounded-lg border p-3"
|
|
110
|
+
:class="
|
|
111
|
+
blocking
|
|
112
|
+
? 'border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40'
|
|
113
|
+
: 'border-default bg-elevated/40'
|
|
114
|
+
"
|
|
115
|
+
:data-tone="tone"
|
|
116
|
+
data-testid="input-gate-notice"
|
|
117
|
+
>
|
|
118
|
+
<div class="flex items-start gap-2">
|
|
119
|
+
<UIcon
|
|
120
|
+
:name="blocking ? 'i-lucide-file-question' : 'i-lucide-info'"
|
|
121
|
+
class="mt-0.5 size-4 shrink-0"
|
|
122
|
+
:class="blocking ? 'text-amber-600 dark:text-amber-400' : 'text-muted'"
|
|
123
|
+
/>
|
|
124
|
+
<div class="min-w-0 flex-1">
|
|
125
|
+
<p class="text-sm font-medium">{{ t(copy.title) }}</p>
|
|
126
|
+
<p v-if="!compact" class="text-muted mt-0.5 text-xs">{{ t(copy.body) }}</p>
|
|
127
|
+
|
|
128
|
+
<ul class="mt-2 space-y-1.5">
|
|
129
|
+
<li v-for="issue in issues" :key="issue.code" class="flex items-start gap-2 text-xs">
|
|
130
|
+
<UBadge
|
|
131
|
+
:color="issue.severity === 'blocking' ? 'warning' : 'neutral'"
|
|
132
|
+
variant="subtle"
|
|
133
|
+
size="sm"
|
|
134
|
+
>
|
|
135
|
+
{{
|
|
136
|
+
issue.severity === 'blocking'
|
|
137
|
+
? t('inputGate.severity.blocking')
|
|
138
|
+
: t('inputGate.severity.advisory')
|
|
139
|
+
}}
|
|
140
|
+
</UBadge>
|
|
141
|
+
<span class="min-w-0">
|
|
142
|
+
<span class="font-medium">{{ issueTitle(issue.code) }}</span>
|
|
143
|
+
<span class="text-muted">, {{ issueHint(issue.code) }}</span>
|
|
144
|
+
</span>
|
|
145
|
+
</li>
|
|
146
|
+
</ul>
|
|
147
|
+
|
|
148
|
+
<div v-if="blocking" class="mt-3 flex flex-wrap items-center gap-2">
|
|
149
|
+
<UButton
|
|
150
|
+
color="primary"
|
|
151
|
+
size="xs"
|
|
152
|
+
icon="i-lucide-refresh-cw"
|
|
153
|
+
:loading="inputGate.resolving"
|
|
154
|
+
data-testid="input-gate-recheck"
|
|
155
|
+
@click="resolve('recheck')"
|
|
156
|
+
>
|
|
157
|
+
{{ t('inputGate.recheck') }}
|
|
158
|
+
</UButton>
|
|
159
|
+
<UButton
|
|
160
|
+
color="neutral"
|
|
161
|
+
variant="ghost"
|
|
162
|
+
size="xs"
|
|
163
|
+
:disabled="inputGate.resolving"
|
|
164
|
+
data-testid="input-gate-proceed"
|
|
165
|
+
@click="resolve('proceed')"
|
|
166
|
+
>
|
|
167
|
+
{{ t('inputGate.proceed') }}
|
|
168
|
+
</UButton>
|
|
169
|
+
<span class="text-muted text-xs">{{ t('inputGate.recheckHint') }}</span>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<p v-if="inputGate.error" class="text-error mt-2 text-xs">{{ inputGate.error }}</p>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
@@ -20,6 +20,7 @@ import { useStepTimer } from '~/composables/useStepTimer'
|
|
|
20
20
|
import { useStepProse } from '~/composables/useStepProse'
|
|
21
21
|
import { useStepApproval } from '~/composables/useStepApproval'
|
|
22
22
|
import { dedicatedParkView } from '~/utils/pipelineRender'
|
|
23
|
+
import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
|
|
23
24
|
|
|
24
25
|
// Detail overlay for a single pipeline step. Opened by clicking an agent in the
|
|
25
26
|
// inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
|
|
@@ -168,7 +169,21 @@ const companionExceeded = computed(() => approvalPending.value && !!step.value?.
|
|
|
168
169
|
// resolver refuses these server-side, so the rail is replaced by a redirect to that window.
|
|
169
170
|
// Computed live, since a coder step can park on one WHILE this overlay is already open
|
|
170
171
|
// (the routing in `dispatchStepView` only covers the open click).
|
|
171
|
-
const dedicatedPark = computed(() =>
|
|
172
|
+
const dedicatedPark = computed(() =>
|
|
173
|
+
step.value ? dedicatedParkView(step.value, instance.value) : null,
|
|
174
|
+
)
|
|
175
|
+
/**
|
|
176
|
+
* The PRE-TOKEN INPUT GATE's verdict when it is what holds this step. Answered INLINE here
|
|
177
|
+
* (unlike the other dedicated parks, which redirect to a window): its remedy is to edit the
|
|
178
|
+
* task, so there is no second modal to send anyone to.
|
|
179
|
+
*
|
|
180
|
+
* Only the PARK, hence the literal `blocked` tone at the call site: this overlay exists to
|
|
181
|
+
* answer one step's park, and an advisory finding is about the run rather than this step. It is
|
|
182
|
+
* reported once, on the run panel, instead of on every step overlay opened under it.
|
|
183
|
+
*/
|
|
184
|
+
const inputGateVerdict = computed(() =>
|
|
185
|
+
dedicatedPark.value === 'input-gate' ? (instance.value?.inputGate ?? null) : null,
|
|
186
|
+
)
|
|
172
187
|
/** The generic approve/request-changes/reject rail applies (no dedicated surface owns the park). */
|
|
173
188
|
const genericApprovalPending = computed(
|
|
174
189
|
() => approvalPending.value && !companionExceeded.value && !dedicatedPark.value,
|
|
@@ -181,7 +196,7 @@ function openDedicatedWindow() {
|
|
|
181
196
|
if (!c || !park) return
|
|
182
197
|
close()
|
|
183
198
|
if (park === 'follow-ups') ui.openFollowUps(c.instanceId, c.stepIndex)
|
|
184
|
-
else ui.openForkDecision(c.instanceId, c.stepIndex)
|
|
199
|
+
else if (park === 'fork-decision') ui.openForkDecision(c.instanceId, c.stepIndex)
|
|
185
200
|
}
|
|
186
201
|
|
|
187
202
|
function close() {
|
|
@@ -433,8 +448,17 @@ async function copyOutput() {
|
|
|
433
448
|
<!-- a park a dedicated window owns (fork choice / follow-up triage): the
|
|
434
449
|
generic approval rail can't resolve it (the server refuses), so point
|
|
435
450
|
the human at the window that can -->
|
|
451
|
+
<!-- the pre-token input gate holds this step: answered here, in place -->
|
|
452
|
+
<InputGateNotice
|
|
453
|
+
v-if="inputGateVerdict && instance"
|
|
454
|
+
:gate="inputGateVerdict"
|
|
455
|
+
tone="blocked"
|
|
456
|
+
:execution-id="instance.id"
|
|
457
|
+
compact
|
|
458
|
+
/>
|
|
459
|
+
|
|
436
460
|
<div
|
|
437
|
-
v-if="dedicatedPark"
|
|
461
|
+
v-if="dedicatedPark && dedicatedPark !== 'input-gate'"
|
|
438
462
|
class="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4"
|
|
439
463
|
data-testid="dedicated-park-redirect"
|
|
440
464
|
>
|
|
@@ -16,6 +16,8 @@ import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
|
|
|
16
16
|
import type { PipelineStep } from '~/types/execution'
|
|
17
17
|
import type { ChangeClass, ReviewEffort } from '~/types/merge'
|
|
18
18
|
import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
|
|
19
|
+
import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
|
|
20
|
+
import { inputGateNoticeFor } from '~/utils/inputGate'
|
|
19
21
|
|
|
20
22
|
const props = defineProps<{ block: Block }>()
|
|
21
23
|
|
|
@@ -58,6 +60,14 @@ const isEmpty = computed(
|
|
|
58
60
|
// A failed run is no longer executing: a step left mid-flight must stop showing
|
|
59
61
|
// its live "Spinning up…" phase (the shared failure banner renders below).
|
|
60
62
|
const runFailed = computed(() => instance.value?.status === 'failed')
|
|
63
|
+
/**
|
|
64
|
+
* The run's PRE-TOKEN INPUT GATE notice: the park while it holds the run, the waiver once
|
|
65
|
+
* somebody overruled it, and the ADVISORY findings a `passed` verdict still carries (which is
|
|
66
|
+
* the entire product of `advisory` mode, and how `standard` mode reports a thin description).
|
|
67
|
+
* Read off the RUN, not a step: the gate guards the first dispatch and leaves nothing
|
|
68
|
+
* kind-specific behind. Which verdicts earn a notice is `inputGateNoticeFor`'s call.
|
|
69
|
+
*/
|
|
70
|
+
const inputGateNotice = computed(() => inputGateNoticeFor(instance.value))
|
|
61
71
|
|
|
62
72
|
// A failed pipeline run surfaces the shared failure banner + retry — the
|
|
63
73
|
// execution failure surface that the old `pr_ready` flip used to hide.
|
|
@@ -298,6 +308,16 @@ async function mergePr() {
|
|
|
298
308
|
</UButton>
|
|
299
309
|
</div>
|
|
300
310
|
</div>
|
|
311
|
+
<!-- What the task's input check found. Rendered above the step list because it is a fact
|
|
312
|
+
about the RUN, and because the remedy for a park is to edit the task, not open a step.
|
|
313
|
+
An advisory verdict renders here too: nothing was parked, but something was found. -->
|
|
314
|
+
<InputGateNotice
|
|
315
|
+
v-if="inputGateNotice"
|
|
316
|
+
:gate="inputGateNotice.gate"
|
|
317
|
+
:tone="inputGateNotice.tone"
|
|
318
|
+
:execution-id="instance.id"
|
|
319
|
+
class="mb-2"
|
|
320
|
+
/>
|
|
301
321
|
<ul class="space-y-1">
|
|
302
322
|
<li
|
|
303
323
|
v-for="(s, i) in instance.steps"
|
|
@@ -417,7 +437,7 @@ async function mergePr() {
|
|
|
417
437
|
v-else-if="
|
|
418
438
|
s.approval &&
|
|
419
439
|
s.approval.status === 'pending' &&
|
|
420
|
-
dedicatedParkView(s) === 'fork-decision'
|
|
440
|
+
dedicatedParkView(s, instance) === 'fork-decision'
|
|
421
441
|
"
|
|
422
442
|
color="primary"
|
|
423
443
|
variant="soft"
|
|
@@ -434,7 +454,7 @@ async function mergePr() {
|
|
|
434
454
|
v-else-if="
|
|
435
455
|
s.approval &&
|
|
436
456
|
s.approval.status === 'pending' &&
|
|
437
|
-
dedicatedParkView(s) === 'follow-ups'
|
|
457
|
+
dedicatedParkView(s, instance) === 'follow-ups'
|
|
438
458
|
"
|
|
439
459
|
color="primary"
|
|
440
460
|
variant="soft"
|
|
@@ -461,8 +481,17 @@ async function mergePr() {
|
|
|
461
481
|
>
|
|
462
482
|
{{ t('inspector.execution.reviewFindings') }}
|
|
463
483
|
</UButton>
|
|
484
|
+
<!-- The generic approve/review rail. Reached only once no dedicated surface owns
|
|
485
|
+
the park: the branches above took the fork and follow-up windows, so the one
|
|
486
|
+
left to exclude is the PRE-TOKEN INPUT GATE, which rides `step.approval` too
|
|
487
|
+
but is refused by the generic resolver server-side (approving it would mark the
|
|
488
|
+
run's first working step done and skip the work). It is answered by the notice
|
|
489
|
+
above the list. Asked of `dedicatedParkView` rather than re-derived here, so
|
|
490
|
+
the rule that decides which surface owns a park lives in exactly one place. -->
|
|
464
491
|
<UButton
|
|
465
|
-
v-else-if="
|
|
492
|
+
v-else-if="
|
|
493
|
+
s.approval && s.approval.status === 'pending' && !dedicatedParkView(s, instance)
|
|
494
|
+
"
|
|
466
495
|
color="warning"
|
|
467
496
|
variant="soft"
|
|
468
497
|
size="xs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// standalone modals).
|
|
12
12
|
import { reactive, ref, watch } from 'vue'
|
|
13
13
|
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
14
|
-
import type { ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
|
|
14
|
+
import type { InputGateMode, ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
|
|
15
15
|
import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
|
|
16
16
|
import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
|
|
17
17
|
import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
|
|
@@ -151,6 +151,12 @@ const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
|
|
|
151
151
|
{ value: 'per_type', label: t('settings.workspaceSettings.taskLimit.modes.per_type') },
|
|
152
152
|
])
|
|
153
153
|
|
|
154
|
+
const INPUT_GATE_MODES = computed<{ value: InputGateMode; label: string }[]>(() => [
|
|
155
|
+
{ value: 'standard', label: t('settings.workspaceSettings.inputGate.modes.standard') },
|
|
156
|
+
{ value: 'advisory', label: t('settings.workspaceSettings.inputGate.modes.advisory') },
|
|
157
|
+
{ value: 'off', label: t('settings.workspaceSettings.inputGate.modes.off') },
|
|
158
|
+
])
|
|
159
|
+
|
|
154
160
|
const REVIEW_FRICTION_MODES = computed<{ value: ReviewFrictionMode; label: string }[]>(() => [
|
|
155
161
|
{ value: 'off', label: t('settings.workspaceSettings.reviewFriction.modes.off') },
|
|
156
162
|
{ value: 'warn', label: t('settings.workspaceSettings.reviewFriction.modes.warn') },
|
|
@@ -175,6 +181,7 @@ const draft = reactive({
|
|
|
175
181
|
artifactRetentionDays: 14,
|
|
176
182
|
kaizenEnabled: true,
|
|
177
183
|
allowInitiatorPat: true,
|
|
184
|
+
inputGateMode: 'standard' as InputGateMode,
|
|
178
185
|
reviewFrictionMode: 'off' as ReviewFrictionMode,
|
|
179
186
|
reviewFrictionWarnCount: 3,
|
|
180
187
|
reviewFrictionBlockCountEnabled: false,
|
|
@@ -195,6 +202,7 @@ function hydrate() {
|
|
|
195
202
|
draft.artifactRetentionDays = s.artifactRetentionDays
|
|
196
203
|
draft.kaizenEnabled = s.kaizenEnabled
|
|
197
204
|
draft.allowInitiatorPat = s.allowInitiatorPat
|
|
205
|
+
draft.inputGateMode = s.inputGateMode
|
|
198
206
|
draft.reviewFrictionMode = s.reviewFrictionMode
|
|
199
207
|
draft.reviewFrictionWarnCount = s.reviewFrictionWarnCount
|
|
200
208
|
// The hard-block knobs are nullable (null ⇒ that trigger is off); a per-trigger checkbox is
|
|
@@ -252,6 +260,7 @@ async function save() {
|
|
|
252
260
|
artifactRetentionDays: draft.artifactRetentionDays,
|
|
253
261
|
kaizenEnabled: draft.kaizenEnabled,
|
|
254
262
|
allowInitiatorPat: draft.allowInitiatorPat,
|
|
263
|
+
inputGateMode: draft.inputGateMode,
|
|
255
264
|
reviewFrictionMode: draft.reviewFrictionMode,
|
|
256
265
|
reviewFrictionWarnCount: draft.reviewFrictionWarnCount,
|
|
257
266
|
reviewFrictionBlockCount: blockCount,
|
|
@@ -359,6 +368,30 @@ async function save() {
|
|
|
359
368
|
</div>
|
|
360
369
|
</section>
|
|
361
370
|
|
|
371
|
+
<!-- The pre-token input gate: the structural check of a task's own wording, run
|
|
372
|
+
before a run's first agent step is dispatched. -->
|
|
373
|
+
<section class="space-y-2">
|
|
374
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
375
|
+
{{ t('settings.workspaceSettings.inputGate.heading') }}
|
|
376
|
+
</h3>
|
|
377
|
+
<p class="text-[11px] text-slate-400">
|
|
378
|
+
{{ t('settings.workspaceSettings.inputGate.body') }}
|
|
379
|
+
</p>
|
|
380
|
+
<label class="block w-64">
|
|
381
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">{{
|
|
382
|
+
t('settings.workspaceSettings.inputGate.mode')
|
|
383
|
+
}}</span>
|
|
384
|
+
<USelect
|
|
385
|
+
v-model="draft.inputGateMode"
|
|
386
|
+
:items="INPUT_GATE_MODES"
|
|
387
|
+
value-key="value"
|
|
388
|
+
size="sm"
|
|
389
|
+
class="w-full"
|
|
390
|
+
data-testid="input-gate-mode"
|
|
391
|
+
/>
|
|
392
|
+
</label>
|
|
393
|
+
</section>
|
|
394
|
+
|
|
362
395
|
<!-- Review-debt friction on task creation -->
|
|
363
396
|
<section class="space-y-2">
|
|
364
397
|
<h3 class="text-sm font-semibold text-slate-200">
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolveInputGateContract, type ResolveInputGateChoice } from '@cat-factory/contracts'
|
|
2
|
+
import type { ApiContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The PRE-TOKEN INPUT GATE: a run whose task states nothing an agent could act on parks before
|
|
6
|
+
* its first dispatch, having spent no tokens. This resolves that park: `recheck` re-evaluates
|
|
7
|
+
* the task as it now stands (the fix is verified, not asserted), `proceed` waives the findings.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately no read: the verdict rides the run (`ExecutionInstance.inputGate`),
|
|
10
|
+
* which the board snapshot and the live stream already carry.
|
|
11
|
+
*/
|
|
12
|
+
export function inputGateApi({ send, ws }: ApiContext) {
|
|
13
|
+
return {
|
|
14
|
+
resolveInputGate: (
|
|
15
|
+
workspaceId: string,
|
|
16
|
+
executionId: string,
|
|
17
|
+
body: { choice: ResolveInputGateChoice },
|
|
18
|
+
) =>
|
|
19
|
+
send(resolveInputGateContract, {
|
|
20
|
+
pathPrefix: ws(workspaceId),
|
|
21
|
+
pathParams: { executionId },
|
|
22
|
+
body,
|
|
23
|
+
}),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -13,6 +13,7 @@ import { documentsApi } from './api/documents'
|
|
|
13
13
|
import { executionApi } from './api/execution'
|
|
14
14
|
import { followUpsApi } from './api/followUps'
|
|
15
15
|
import { forkDecisionApi } from './api/forkDecision'
|
|
16
|
+
import { inputGateApi } from './api/inputGate'
|
|
16
17
|
import { judgeApi } from './api/judge'
|
|
17
18
|
import { prReviewApi } from './api/prReview'
|
|
18
19
|
import { fragmentsApi } from './api/fragments'
|
|
@@ -129,6 +130,7 @@ export function useApi() {
|
|
|
129
130
|
...reviewsApi(ctx),
|
|
130
131
|
...followUpsApi(ctx),
|
|
131
132
|
...forkDecisionApi(ctx),
|
|
133
|
+
...inputGateApi(ctx),
|
|
132
134
|
...judgeApi(ctx),
|
|
133
135
|
...prReviewApi(ctx),
|
|
134
136
|
...humanTestApi(ctx),
|
|
@@ -80,6 +80,14 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
80
80
|
titleKey: 'errors.conflict.title.dependencies_unmet',
|
|
81
81
|
descriptionKey: 'errors.conflict.description.dependencies_unmet',
|
|
82
82
|
},
|
|
83
|
+
input_gate_not_parked: {
|
|
84
|
+
titleKey: 'errors.conflict.title.input_gate_not_parked',
|
|
85
|
+
descriptionKey: 'errors.conflict.description.input_gate_not_parked',
|
|
86
|
+
},
|
|
87
|
+
input_gate_parked: {
|
|
88
|
+
titleKey: 'errors.conflict.title.input_gate_parked',
|
|
89
|
+
descriptionKey: 'errors.conflict.description.input_gate_parked',
|
|
90
|
+
},
|
|
83
91
|
task_limit_reached: {
|
|
84
92
|
titleKey: 'errors.conflict.title.task_limit_reached',
|
|
85
93
|
descriptionKey: 'errors.conflict.description.task_limit_reached',
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { ResolveInputGateChoice, RunInputGate } from '@cat-factory/contracts'
|
|
4
|
+
import { useApi } from '~/composables/useApi'
|
|
5
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The PRE-TOKEN INPUT GATE's action surface. The verdict itself lives on the run
|
|
10
|
+
* (`instance.inputGate`) and is kept fresh by the execution stream, so this store only wraps the
|
|
11
|
+
* `resolve` action, tracks the in-flight state so the notice can disable its buttons, and
|
|
12
|
+
* reflects the returned verdict back so the UI updates before the stream echoes it.
|
|
13
|
+
*
|
|
14
|
+
* The echo goes through {@link ExecutionStore.echoAfter} rather than a bare assignment: a
|
|
15
|
+
* successful resolve WAKES THE DURABLE DRIVER, whose next emit routinely beats this HTTP
|
|
16
|
+
* response, so an unguarded write would put the released run back into `blocked` and, if the
|
|
17
|
+
* run then parks on something else, leave it there with nothing left to emit.
|
|
18
|
+
*/
|
|
19
|
+
export const useInputGateStore = defineStore('inputGate', () => {
|
|
20
|
+
const api = useApi()
|
|
21
|
+
const workspace = useWorkspaceStore()
|
|
22
|
+
const execution = useExecutionStore()
|
|
23
|
+
|
|
24
|
+
/** True while a resolve call is in flight (drives the buttons' spinner / disabled state). */
|
|
25
|
+
const resolving = ref(false)
|
|
26
|
+
/** The last error message from an action, surfaced inline; cleared on the next action. */
|
|
27
|
+
const error = ref<string | null>(null)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the parked gate. `recheck` re-evaluates the task as it now stands and releases the
|
|
31
|
+
* run only if the blocking gaps are genuinely gone. A still-blocked verdict comes back as an
|
|
32
|
+
* ordinary 200 with refreshed findings rather than an error, because nothing went wrong: the
|
|
33
|
+
* task is just not fixed yet. `proceed` waives the findings.
|
|
34
|
+
*/
|
|
35
|
+
async function resolve(
|
|
36
|
+
executionId: string,
|
|
37
|
+
choice: ResolveInputGateChoice,
|
|
38
|
+
): Promise<RunInputGate | null> {
|
|
39
|
+
error.value = null
|
|
40
|
+
resolving.value = true
|
|
41
|
+
try {
|
|
42
|
+
return await execution.echoAfter(
|
|
43
|
+
executionId,
|
|
44
|
+
() => api.resolveInputGate(workspace.requireId(), executionId, { choice }),
|
|
45
|
+
(gate, instance) => {
|
|
46
|
+
instance.inputGate = gate as RunInputGate
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
error.value = e instanceof Error ? e.message : 'Failed to resolve'
|
|
51
|
+
throw e
|
|
52
|
+
} finally {
|
|
53
|
+
resolving.value = false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { resolving, error, resolve }
|
|
58
|
+
})
|