@cat-factory/app 0.182.0 → 0.183.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 +16 -3
- package/app/components/board/nodes/BlockNode.vue +14 -4
- package/app/components/board/nodes/InitiativeCard.vue +27 -2
- package/app/components/initiative/InitiativeTrackerWindow.vue +149 -9
- package/app/components/layout/IntegrationsHub.vue +6 -19
- package/app/components/panels/inspector/InitiativeInspector.vue +23 -2
- package/app/components/settings/InfrastructureWindow.logic.spec.ts +100 -0
- package/app/components/settings/InfrastructureWindow.logic.ts +78 -0
- package/app/components/settings/InfrastructureWindow.vue +65 -46
- package/app/components/settings/PackageRegistriesPanel.vue +131 -123
- package/app/composables/useInitiativePlanning.ts +70 -0
- package/app/modular/nav-contributions.spec.ts +13 -3
- package/app/modular/nav-contributions.ts +42 -32
- package/app/pages/index.vue +0 -1
- package/app/stores/packageRegistries.spec.ts +111 -0
- package/app/stores/packageRegistries.ts +21 -8
- package/app/stores/ui/modals.ts +20 -21
- package/app/types/providerConnections.ts +12 -0
- package/app/utils/initiative.spec.ts +51 -1
- package/app/utils/initiative.ts +44 -0
- package/i18n/locales/de.json +15 -7
- package/i18n/locales/en.json +15 -7
- package/i18n/locales/es.json +15 -7
- package/i18n/locales/fr.json +15 -7
- package/i18n/locales/he.json +15 -7
- package/i18n/locales/it.json +15 -7
- package/i18n/locales/ja.json +15 -7
- package/i18n/locales/pl.json +15 -7
- package/i18n/locales/tr.json +15 -7
- package/i18n/locales/uk.json +15 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -177,9 +177,13 @@ Two placements are load-bearing enough to state, because putting a new one in th
|
|
|
177
177
|
wrong place is invisible until a user cannot find it:
|
|
178
178
|
|
|
179
179
|
- **The sidebar section is a claim about what the destination IS.** `models` is the
|
|
180
|
-
engines,
|
|
181
|
-
|
|
182
|
-
`
|
|
180
|
+
model layer — the engines, the per-agent model choice, and the surfaces that
|
|
181
|
+
evaluate a prompt+agent+model combination (Sandbox, Kaizen); `integrations` the
|
|
182
|
+
optional EXTERNAL systems; `infrastructure` where agent containers and test
|
|
183
|
+
environments run; `configuration` workspace/account settings. A surface that
|
|
184
|
+
connects to nothing does not belong in `integrations` however configuration-shaped
|
|
185
|
+
it feels. `nav-contributions.spec.ts` pins the section order and each section's
|
|
186
|
+
membership.
|
|
183
187
|
- **A flow that edits ONE entity's config is a section of the window that owns that
|
|
184
188
|
config, not a sibling nav entry.** The guided Docker Compose environment setup
|
|
185
189
|
(`ComposeEnvironmentSetupSection.vue` → `EnvironmentSetupWizard.vue`) lives inside
|
|
@@ -188,6 +192,15 @@ wrong place is invisible until a user cannot find it:
|
|
|
188
192
|
It also carries a full "how it works / when you need this / when you can skip it"
|
|
189
193
|
explanation there rather than a one-line hint, since the decision to run it has to
|
|
190
194
|
be made before opening a five-minute wizard.
|
|
195
|
+
- **"It talks to an external service" is not what puts a surface in `integrations`.**
|
|
196
|
+
Private package registries connect to npmjs.com and GitHub Packages and still belong
|
|
197
|
+
in Infrastructure, because the question they answer is _what may a container install
|
|
198
|
+
from_ — a property of where agents RUN, which is what the Infrastructure window is
|
|
199
|
+
for. `integrations` is for a system the WORKSPACE links in and would still be a
|
|
200
|
+
coherent product without. Ask which question the destination answers, not whether a
|
|
201
|
+
credential leaves the building. A surface moved between sections must also move its
|
|
202
|
+
entry point: leaving a hub row behind as a shortcut splits the answer across two
|
|
203
|
+
places, so the row goes and the window's tab becomes the single route in.
|
|
191
204
|
|
|
192
205
|
## Develop & test
|
|
193
206
|
|
|
@@ -83,12 +83,22 @@ const selected = computed(() => ui.selectedBlockId === props.id)
|
|
|
83
83
|
// kept (gated off) so the prior behaviour is one edit away if we want chips back.
|
|
84
84
|
const showExpanded = computed(() => true)
|
|
85
85
|
|
|
86
|
-
//
|
|
86
|
+
// Every child whose parked run the frame badge speaks for: its tasks AND its initiative
|
|
87
|
+
// blocks. An initiative is a frame child like a module and runs an ordinary pipeline (its
|
|
88
|
+
// planner parks on a real approval gate), so leaving it out made a whole class of parked run
|
|
89
|
+
// invisible at frame level — the badge read "nothing needs you" while a plan sat waiting.
|
|
90
|
+
const attentionIds = computed(() => {
|
|
91
|
+
const ids = new Set(taskIds.value)
|
|
92
|
+
for (const i of initiativeBlocks.value) ids.add(i.id)
|
|
93
|
+
return ids
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Surface a pending decision from this frame OR any of its children (O(children) map
|
|
87
97
|
// lookups, not a scan of every open decision per frame).
|
|
88
98
|
const blockDecisions = computed(() => {
|
|
89
99
|
const byBlock = execution.decisionsByBlock
|
|
90
100
|
const out = [...(byBlock.get(props.id) ?? [])]
|
|
91
|
-
for (const id of
|
|
101
|
+
for (const id of attentionIds.value) {
|
|
92
102
|
const list = byBlock.get(id)
|
|
93
103
|
if (list) out.push(...list)
|
|
94
104
|
}
|
|
@@ -100,14 +110,14 @@ function openFirstDecision() {
|
|
|
100
110
|
if (d) ui.openDecision(d.instanceId, d.decision.id)
|
|
101
111
|
}
|
|
102
112
|
|
|
103
|
-
// Surface a pending approval gate from this frame OR any of its
|
|
113
|
+
// Surface a pending approval gate from this frame OR any of its children — but NOT an
|
|
104
114
|
// iterative reviewer gate (requirements-review / clarity-review) that's mid-cycle
|
|
105
115
|
// (incorporating / re-reviewing in the driver), which is background work needing no human,
|
|
106
116
|
// so it stays off the frame's "Approval" badge.
|
|
107
117
|
const blockApprovals = computed(() => {
|
|
108
118
|
const byBlock = execution.approvalsByBlock
|
|
109
119
|
const candidates = [...(byBlock.get(props.id) ?? [])]
|
|
110
|
-
for (const id of
|
|
120
|
+
for (const id of attentionIds.value) {
|
|
111
121
|
const list = byBlock.get(id)
|
|
112
122
|
if (list) candidates.push(...list)
|
|
113
123
|
}
|
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
// initiative's equivalent "Run planning" (and, while parked mid-interview, "Answer
|
|
6
6
|
// planning questions") lives right here on the board — the same actions the
|
|
7
7
|
// inspector offers — so starting an initiative isn't hidden behind selecting it.
|
|
8
|
+
// It likewise mirrors a task card's `attention` affordance: a planning run parked
|
|
9
|
+
// on the plan-approval gate (or on an agent-raised decision) offers the button that
|
|
10
|
+
// opens the window resolving it, instead of leaving the card on a spinning "Run
|
|
11
|
+
// planning" whose only route in was the inspector's execution panel.
|
|
8
12
|
// The tracker button opens the dedicated window directly. Draggable within its
|
|
9
13
|
// frame like a task card.
|
|
10
14
|
import type { InitiativeStatus } from '~/types/domain'
|
|
11
15
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
12
16
|
import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
|
|
13
17
|
import {
|
|
18
|
+
INITIATIVE_ATTENTION_ICONS,
|
|
19
|
+
INITIATIVE_ATTENTION_LABEL_KEYS,
|
|
14
20
|
INITIATIVE_STATUS_CHIPS,
|
|
15
21
|
INITIATIVE_STATUS_LABEL_KEYS,
|
|
16
22
|
initiativeProgress,
|
|
@@ -40,6 +46,7 @@ const {
|
|
|
40
46
|
running,
|
|
41
47
|
awaitingAnswers,
|
|
42
48
|
interviewing,
|
|
49
|
+
attention,
|
|
43
50
|
starting,
|
|
44
51
|
runPlanning,
|
|
45
52
|
openPlanning,
|
|
@@ -79,7 +86,10 @@ function onHandle(e: PointerEvent) {
|
|
|
79
86
|
data-testid="initiative-card"
|
|
80
87
|
:data-status="status"
|
|
81
88
|
class="cursor-pointer rounded-b-lg border border-indigo-800/60 bg-indigo-950/40 p-3 transition hover:border-indigo-600"
|
|
82
|
-
:class="[
|
|
89
|
+
:class="[
|
|
90
|
+
selected ? 'ring-2 ring-indigo-400/60' : '',
|
|
91
|
+
awaitingAnswers || attention ? 'board-pulse' : '',
|
|
92
|
+
]"
|
|
83
93
|
@click.stop="select"
|
|
84
94
|
>
|
|
85
95
|
<div class="flex items-start justify-between gap-2">
|
|
@@ -106,8 +116,23 @@ function onHandle(e: PointerEvent) {
|
|
|
106
116
|
</div>
|
|
107
117
|
</div>
|
|
108
118
|
<div class="nodrag mt-2 flex flex-wrap items-center gap-1">
|
|
119
|
+
<!-- Parked for a human: the drafted plan awaits approval, or an agent raised a
|
|
120
|
+
decision. Opens the window that can resolve the park (never the generic panel,
|
|
121
|
+
which the server refuses for a park a dedicated window owns). -->
|
|
109
122
|
<UButton
|
|
110
|
-
v-if="
|
|
123
|
+
v-if="attention"
|
|
124
|
+
data-testid="initiative-card-review"
|
|
125
|
+
:data-attention="attention.kind"
|
|
126
|
+
size="xs"
|
|
127
|
+
variant="solid"
|
|
128
|
+
color="warning"
|
|
129
|
+
:icon="INITIATIVE_ATTENTION_ICONS[attention.kind]"
|
|
130
|
+
@click.stop="attention.open()"
|
|
131
|
+
>
|
|
132
|
+
{{ t(INITIATIVE_ATTENTION_LABEL_KEYS[attention.kind]) }}
|
|
133
|
+
</UButton>
|
|
134
|
+
<UButton
|
|
135
|
+
v-else-if="awaitingAnswers"
|
|
111
136
|
data-testid="initiative-card-answer-planning"
|
|
112
137
|
size="xs"
|
|
113
138
|
variant="solid"
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// The initiative tracker window — the dedicated
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
2
|
+
// The initiative tracker window — the dedicated view of an initiative's plan/tracker
|
|
3
|
+
// entity: goal + constraints, the phases with their per-item status + PR links, the
|
|
4
|
+
// execution policy, and the decisions / deviations / follow-ups / caveats logs.
|
|
5
|
+
// Renders the DB entity (the source of truth) — never the in-repo mirror, which may
|
|
6
|
+
// not exist (GitHub-unwired workspaces). Opened via the universal result-view host:
|
|
7
|
+
// from the board card / inspector (`ui.openInitiativeTracker`) or as the planner
|
|
8
|
+
// step's result view. Live `initiative` stream events patch the store, so an open
|
|
9
|
+
// window follows the plan as it is ingested and later executed.
|
|
10
|
+
//
|
|
11
|
+
// It also OWNS the planner's plan-approval gate: this window is where the planner
|
|
12
|
+
// step's park routes (its archetype declares this result view), so the approve /
|
|
13
|
+
// request-changes rail has to live here or the gate has no resolving surface at all —
|
|
14
|
+
// which is exactly how an approved-only-over-REST plan gate shipped.
|
|
15
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
11
16
|
import type { InitiativeFollowUp, InitiativeItem } from '~/types/domain'
|
|
17
|
+
import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
|
|
12
18
|
import {
|
|
13
19
|
INITIATIVE_FOLLOWUP_STATUS_CHIPS,
|
|
14
20
|
INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS,
|
|
@@ -23,6 +29,8 @@ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
|
23
29
|
|
|
24
30
|
const board = useBoardStore()
|
|
25
31
|
const initiatives = useInitiativesStore()
|
|
32
|
+
const execution = useExecutionStore()
|
|
33
|
+
const access = useWorkspaceAccess()
|
|
26
34
|
const { t } = useI18n()
|
|
27
35
|
const toast = useToast()
|
|
28
36
|
|
|
@@ -92,6 +100,58 @@ async function checkpointControl(action: 'resume' | 'cancel') {
|
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
// ---- Plan review: the planner step's human gate, resolved right here -----------------------
|
|
104
|
+
// Derived from the BLOCK (via the shared planning composable), not from this window's own
|
|
105
|
+
// `stepIndex`: the card / inspector open the tracker with no step, and that is the entry point a
|
|
106
|
+
// human parked on the gate actually uses. So the rail appears on every route into the window.
|
|
107
|
+
const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
|
|
108
|
+
|
|
109
|
+
/** Draft feedback for "request changes" (the planner re-runs with it), and the rail's in-flight
|
|
110
|
+
* state. Reset when a different initiative opens so a draft can't follow the window. */
|
|
111
|
+
const planFeedback = ref('')
|
|
112
|
+
const requestingChanges = ref(false)
|
|
113
|
+
const resolvingPlan = ref(false)
|
|
114
|
+
watch(blockId, () => {
|
|
115
|
+
planFeedback.value = ''
|
|
116
|
+
requestingChanges.value = false
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const canRequestChanges = computed(() => planFeedback.value.trim().length > 0)
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Accept the drafted plan: the run advances to the committer, which persists the initiative and
|
|
123
|
+
* arms the execution loop. The window deliberately stays OPEN — the rail disappears with the
|
|
124
|
+
* approval (live), and the tracker is where the plan then starts executing.
|
|
125
|
+
*/
|
|
126
|
+
async function approvePlan() {
|
|
127
|
+
const parked = planApproval.value
|
|
128
|
+
if (!parked || resolvingPlan.value) return
|
|
129
|
+
resolvingPlan.value = true
|
|
130
|
+
try {
|
|
131
|
+
await execution.approveStep(parked.instanceId, parked.approval.id)
|
|
132
|
+
} finally {
|
|
133
|
+
resolvingPlan.value = false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Send the plan back to the planner with what to change; it re-plans and parks again. */
|
|
138
|
+
async function submitPlanChanges() {
|
|
139
|
+
const parked = planApproval.value
|
|
140
|
+
if (!parked || resolvingPlan.value || !canRequestChanges.value) return
|
|
141
|
+
resolvingPlan.value = true
|
|
142
|
+
try {
|
|
143
|
+
const ok = await execution.requestStepChanges(parked.instanceId, parked.approval.id, {
|
|
144
|
+
feedback: planFeedback.value.trim(),
|
|
145
|
+
})
|
|
146
|
+
if (ok) {
|
|
147
|
+
planFeedback.value = ''
|
|
148
|
+
requestingChanges.value = false
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
resolvingPlan.value = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
95
155
|
const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
|
|
96
156
|
function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
|
|
97
157
|
const axes = [
|
|
@@ -234,6 +294,86 @@ async function savePolicy() {
|
|
|
234
294
|
</div>
|
|
235
295
|
|
|
236
296
|
<template v-else>
|
|
297
|
+
<!-- The planner's human gate: the plan below is drafted but NOT yet committed. This
|
|
298
|
+
is the only surface that can resolve it (the generic approval panel is never
|
|
299
|
+
reached — the planner's archetype routes its park to this window), so approve /
|
|
300
|
+
request changes live here, beside the plan they judge. -->
|
|
301
|
+
<section
|
|
302
|
+
v-if="planApproval"
|
|
303
|
+
class="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3.5"
|
|
304
|
+
data-testid="initiative-plan-review"
|
|
305
|
+
>
|
|
306
|
+
<div class="flex items-start gap-2.5">
|
|
307
|
+
<UIcon
|
|
308
|
+
name="i-lucide-clipboard-check"
|
|
309
|
+
class="mt-0.5 h-4 w-4 shrink-0 text-amber-300"
|
|
310
|
+
/>
|
|
311
|
+
<div class="min-w-0 flex-1">
|
|
312
|
+
<h3 class="text-[13px] font-semibold text-amber-200">
|
|
313
|
+
{{ t('initiative.planReview.title') }}
|
|
314
|
+
</h3>
|
|
315
|
+
<p class="mt-0.5 text-[12px] leading-relaxed text-amber-100/80">
|
|
316
|
+
{{ t('initiative.planReview.body') }}
|
|
317
|
+
</p>
|
|
318
|
+
<div v-if="!requestingChanges" class="mt-2.5 flex flex-wrap gap-2">
|
|
319
|
+
<button
|
|
320
|
+
class="rounded bg-indigo-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
|
|
321
|
+
:disabled="resolvingPlan || !access.canExecuteRuns.value"
|
|
322
|
+
:title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
|
|
323
|
+
data-testid="initiative-plan-approve"
|
|
324
|
+
@click="approvePlan"
|
|
325
|
+
>
|
|
326
|
+
{{ t('initiative.planReview.approve') }}
|
|
327
|
+
</button>
|
|
328
|
+
<button
|
|
329
|
+
class="rounded border border-amber-400/50 px-2.5 py-1 text-[11px] font-medium text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
|
|
330
|
+
:disabled="resolvingPlan || !access.canExecuteRuns.value"
|
|
331
|
+
:title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
|
|
332
|
+
data-testid="initiative-plan-request-changes"
|
|
333
|
+
@click="requestingChanges = true"
|
|
334
|
+
>
|
|
335
|
+
{{ t('initiative.planReview.requestChanges') }}
|
|
336
|
+
</button>
|
|
337
|
+
</div>
|
|
338
|
+
<!-- Request-changes composer: the feedback is what the planner re-plans FROM,
|
|
339
|
+
so it is required — an empty send would re-run the planner with nothing
|
|
340
|
+
to act on and park again on the same plan. -->
|
|
341
|
+
<div v-else class="mt-2.5">
|
|
342
|
+
<UTextarea
|
|
343
|
+
v-model="planFeedback"
|
|
344
|
+
:rows="3"
|
|
345
|
+
autoresize
|
|
346
|
+
size="sm"
|
|
347
|
+
class="w-full"
|
|
348
|
+
data-testid="initiative-plan-feedback"
|
|
349
|
+
:placeholder="t('initiative.planReview.feedbackPlaceholder')"
|
|
350
|
+
/>
|
|
351
|
+
<div class="mt-2 flex flex-wrap gap-2">
|
|
352
|
+
<button
|
|
353
|
+
class="rounded bg-amber-500 px-2.5 py-1 text-[11px] font-medium text-slate-950 hover:bg-amber-400 disabled:opacity-50"
|
|
354
|
+
:disabled="
|
|
355
|
+
resolvingPlan || !canRequestChanges || !access.canExecuteRuns.value
|
|
356
|
+
"
|
|
357
|
+
:title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
|
|
358
|
+
data-testid="initiative-plan-send-back"
|
|
359
|
+
@click="submitPlanChanges"
|
|
360
|
+
>
|
|
361
|
+
{{ t('initiative.planReview.sendBack') }}
|
|
362
|
+
</button>
|
|
363
|
+
<button
|
|
364
|
+
class="rounded border border-slate-600 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
|
365
|
+
:disabled="resolvingPlan"
|
|
366
|
+
data-testid="initiative-plan-cancel-changes"
|
|
367
|
+
@click="requestingChanges = false"
|
|
368
|
+
>
|
|
369
|
+
{{ t('common.cancel') }}
|
|
370
|
+
</button>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</section>
|
|
376
|
+
|
|
237
377
|
<!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
|
|
238
378
|
review before the next phase spawns. Read the phase's artifacts/PRs below,
|
|
239
379
|
then resume (continue) or cancel (stop) the initiative right here. -->
|
|
@@ -30,7 +30,6 @@ const documents = useDocumentsStore()
|
|
|
30
30
|
const tasks = useTasksStore()
|
|
31
31
|
const tracker = useTrackerStore()
|
|
32
32
|
const releaseHealth = useReleaseHealthStore()
|
|
33
|
-
const packageRegistries = usePackageRegistriesStore()
|
|
34
33
|
const publicApiKeys = usePublicApiKeysStore()
|
|
35
34
|
const userSecrets = useUserSecretsStore()
|
|
36
35
|
const uiMode = useUiModeStore()
|
|
@@ -60,7 +59,6 @@ watch(
|
|
|
60
59
|
if (isOpen) {
|
|
61
60
|
query.value = ''
|
|
62
61
|
void releaseHealth.ensureLoaded().catch(() => {})
|
|
63
|
-
void packageRegistries.ensureLoaded().catch(() => {})
|
|
64
62
|
void publicApiKeys.ensureLoaded().catch(() => {})
|
|
65
63
|
void userSecrets.load().catch(() => {})
|
|
66
64
|
}
|
|
@@ -271,22 +269,10 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
271
269
|
})
|
|
272
270
|
}
|
|
273
271
|
|
|
274
|
-
// --- Development (
|
|
275
|
-
//
|
|
276
|
-
//
|
|
272
|
+
// --- Development (API access tokens) ---------------------------------------
|
|
273
|
+
// Gated like observability: hidden until a probe confirms its module is wired
|
|
274
|
+
// (`available === true`), so an unconfigured backend doesn't show a dead row.
|
|
277
275
|
const development: IntegrationItem[] = []
|
|
278
|
-
if (packageRegistries.available) {
|
|
279
|
-
const hasEntries = packageRegistries.entries.length > 0
|
|
280
|
-
development.push({
|
|
281
|
-
key: 'package-registries',
|
|
282
|
-
icon: 'i-lucide-package',
|
|
283
|
-
label: t('layout.integrationsHub.items.packageRegistries.label'),
|
|
284
|
-
description: t('layout.integrationsHub.items.packageRegistries.description'),
|
|
285
|
-
status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
|
|
286
|
-
connected: hasEntries,
|
|
287
|
-
onClick: () => go(ui.openPackageRegistries),
|
|
288
|
-
})
|
|
289
|
-
}
|
|
290
276
|
if (publicApiKeys.available) {
|
|
291
277
|
const hasKeys = publicApiKeys.keys.length > 0
|
|
292
278
|
development.push({
|
|
@@ -303,8 +289,9 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
303
289
|
out.push({ title: t('layout.integrationsHub.groups.development'), items: development })
|
|
304
290
|
|
|
305
291
|
// NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
|
|
306
|
-
// warm pool/checkout
|
|
307
|
-
// (SideBar → "Infrastructure" → the
|
|
292
|
+
// warm pool/checkout + the private package registries a checkout installs from) is no longer
|
|
293
|
+
// listed here — it moved to its OWN top-level navbar menu (SideBar → "Infrastructure" → the
|
|
294
|
+
// tabbed Infrastructure window). See `ui.openInfrastructure`.
|
|
308
295
|
|
|
309
296
|
// --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
|
|
310
297
|
// Per-user connections normally live in the My-setup hub; with auth disabled they fold in
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
// with slice 4.
|
|
7
7
|
import type { Block, InitiativeStatus } from '~/types/domain'
|
|
8
8
|
import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
INITIATIVE_ATTENTION_ICONS,
|
|
11
|
+
INITIATIVE_ATTENTION_LABEL_KEYS,
|
|
12
|
+
INITIATIVE_STATUS_LABEL_KEYS,
|
|
13
|
+
initiativeProgress,
|
|
14
|
+
} from '~/utils/initiative'
|
|
10
15
|
|
|
11
16
|
const props = defineProps<{ block: Block }>()
|
|
12
17
|
|
|
@@ -24,6 +29,7 @@ const {
|
|
|
24
29
|
running,
|
|
25
30
|
awaitingAnswers,
|
|
26
31
|
interviewing,
|
|
32
|
+
attention,
|
|
27
33
|
starting,
|
|
28
34
|
runPlanning,
|
|
29
35
|
openPlanning,
|
|
@@ -56,8 +62,23 @@ function control(action: 'pause' | 'resume' | 'cancel') {
|
|
|
56
62
|
</p>
|
|
57
63
|
|
|
58
64
|
<div class="flex flex-wrap items-center gap-2">
|
|
65
|
+
<!-- Parked for a human (the drafted plan awaits approval, or an agent raised a decision).
|
|
66
|
+
The same affordance the board card carries, resolved from the same composable — the
|
|
67
|
+
run's park must not be reachable only through the execution panel's step list. -->
|
|
59
68
|
<UButton
|
|
60
|
-
v-if="
|
|
69
|
+
v-if="attention"
|
|
70
|
+
data-testid="initiative-review"
|
|
71
|
+
:data-attention="attention.kind"
|
|
72
|
+
color="warning"
|
|
73
|
+
variant="solid"
|
|
74
|
+
size="sm"
|
|
75
|
+
:icon="INITIATIVE_ATTENTION_ICONS[attention.kind]"
|
|
76
|
+
@click="attention.open()"
|
|
77
|
+
>
|
|
78
|
+
{{ t(INITIATIVE_ATTENTION_LABEL_KEYS[attention.kind]) }}
|
|
79
|
+
</UButton>
|
|
80
|
+
<UButton
|
|
81
|
+
v-else-if="awaitingAnswers"
|
|
61
82
|
data-testid="initiative-answer-planning"
|
|
62
83
|
color="primary"
|
|
63
84
|
variant="solid"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
infrastructureTabs,
|
|
4
|
+
openInfrastructureTab,
|
|
5
|
+
repinInfrastructureTab,
|
|
6
|
+
} from './InfrastructureWindow.logic'
|
|
7
|
+
|
|
8
|
+
const NONE = { agents: false, environments: false, packageRegistries: false }
|
|
9
|
+
|
|
10
|
+
describe('infrastructureTabs', () => {
|
|
11
|
+
it('shows nothing when no probe reports a backend', () => {
|
|
12
|
+
expect(infrastructureTabs(NONE)).toEqual([])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('rides the environment probe for shared stacks', () => {
|
|
16
|
+
// A shared stack is infra a Tester environment ATTACHES to, so it cannot stand on its own:
|
|
17
|
+
// without an environment backend there is nothing to attach it to.
|
|
18
|
+
expect(infrastructureTabs({ ...NONE, environments: true })).toEqual([
|
|
19
|
+
'environment',
|
|
20
|
+
'shared-stacks',
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('gates the registries tab on its own module probe', () => {
|
|
25
|
+
// The backend 503s the module with no encryption key. That is independent of any execution
|
|
26
|
+
// or test-env backend, so the tab can be the ONLY one a deployment shows.
|
|
27
|
+
expect(infrastructureTabs({ ...NONE, packageRegistries: true })).toEqual(['package-registries'])
|
|
28
|
+
expect(infrastructureTabs({ ...NONE, agents: true })).toEqual(['runner-pool'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('orders tabs by the question they answer, not by which probe resolved', () => {
|
|
32
|
+
expect(
|
|
33
|
+
infrastructureTabs({ agents: true, environments: true, packageRegistries: true }),
|
|
34
|
+
).toEqual(['runner-pool', 'environment', 'shared-stacks', 'package-registries'])
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('openInfrastructureTab', () => {
|
|
39
|
+
it('honours an available deep-linked tab over the first one', () => {
|
|
40
|
+
expect(openInfrastructureTab(['runner-pool', 'package-registries'], 'package-registries')).toBe(
|
|
41
|
+
'package-registries',
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('falls back to the first available tab when the request is off', () => {
|
|
46
|
+
expect(openInfrastructureTab(['runner-pool'], 'package-registries')).toBe('runner-pool')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('keeps naming the requested tab when nothing is available yet', () => {
|
|
50
|
+
// Every probe is still in flight on the first open. Holding the request (rather than
|
|
51
|
+
// inventing a selection) is what lets the re-pin land on it the moment its probe resolves.
|
|
52
|
+
expect(openInfrastructureTab([], 'package-registries')).toBe('package-registries')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('repinInfrastructureTab', () => {
|
|
57
|
+
it('leaves the user where they are when their tab is still available', () => {
|
|
58
|
+
// The list GROWS as probes resolve; someone reading a tab must not be yanked off it.
|
|
59
|
+
expect(
|
|
60
|
+
repinInfrastructureTab(
|
|
61
|
+
['runner-pool', 'environment', 'package-registries'],
|
|
62
|
+
'environment',
|
|
63
|
+
'runner-pool',
|
|
64
|
+
true,
|
|
65
|
+
),
|
|
66
|
+
).toBe('environment')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('lands on the deep-linked tab once its probe resolves', () => {
|
|
70
|
+
// The registries probe is the slow one: the window opened on `runner-pool` because
|
|
71
|
+
// `package-registries` was not there yet, and this is the moment it arrives.
|
|
72
|
+
expect(
|
|
73
|
+
repinInfrastructureTab(
|
|
74
|
+
['runner-pool', 'package-registries'],
|
|
75
|
+
'shared-stacks',
|
|
76
|
+
'package-registries',
|
|
77
|
+
true,
|
|
78
|
+
),
|
|
79
|
+
).toBe('package-registries')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('does not steal the selection while the probes are still settling', () => {
|
|
83
|
+
// A transient list holding only the fastest probe's tab. Falling back to it here would pin
|
|
84
|
+
// the user to whichever probe won the race and then REJECT the deep link for being a peer
|
|
85
|
+
// of the current tab.
|
|
86
|
+
expect(
|
|
87
|
+
repinInfrastructureTab(['runner-pool'], 'package-registries', 'package-registries', false),
|
|
88
|
+
).toBe('package-registries')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('falls back to the first tab once loading settled and the request is unavailable', () => {
|
|
92
|
+
expect(
|
|
93
|
+
repinInfrastructureTab(['runner-pool'], 'package-registries', 'package-registries', true),
|
|
94
|
+
).toBe('runner-pool')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('holds the current tab when settling left nothing available at all', () => {
|
|
98
|
+
expect(repinInfrastructureTab([], 'environment', 'environment', true)).toBe('environment')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { InfrastructureTab } from '~/types/providerConnections'
|
|
2
|
+
|
|
3
|
+
// Pure tab arithmetic behind `InfrastructureWindow.vue`: which tabs exist for this deployment,
|
|
4
|
+
// and which one to show. Extracted so it is unit-tested directly rather than through the DOM —
|
|
5
|
+
// the window's tabs come from THREE probes that resolve independently (the infrastructure
|
|
6
|
+
// capability, the provider-connection store, the package-registries module), so the interesting
|
|
7
|
+
// behaviour is what happens while the list is still growing, which a rendered-component test
|
|
8
|
+
// reproduces only by accident.
|
|
9
|
+
//
|
|
10
|
+
// Labels stay in the component: a label resolved here would have to travel as a message KEY and
|
|
11
|
+
// be fed to `t()` as a variable, which is exactly what defeats the typed-message-key check. The
|
|
12
|
+
// component maps a tab onto a literal `t()` call through an exhaustive Record instead.
|
|
13
|
+
|
|
14
|
+
/** Which of the window's tabs this deployment can show, as their probes currently read. */
|
|
15
|
+
export interface InfrastructureTabAvailability {
|
|
16
|
+
/** An execution backend is reported (runner pool / container runtime / local). */
|
|
17
|
+
agents: boolean
|
|
18
|
+
/** A test-environment backend is reported. Also gates the shared-stacks tab. */
|
|
19
|
+
environments: boolean
|
|
20
|
+
/** The package-registries module answered its probe affirmatively (it 503s unconfigured). */
|
|
21
|
+
packageRegistries: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The window's tabs, in display order. Order is the reading order of the questions they answer:
|
|
26
|
+
* where agent containers run, where test environments run, what those environments attach to,
|
|
27
|
+
* and what a checkout may install from.
|
|
28
|
+
*
|
|
29
|
+
* Shared stacks ride the test-environment probe because a stack is infra an environment attaches
|
|
30
|
+
* to — there is nothing to attach without an environment backend.
|
|
31
|
+
*/
|
|
32
|
+
export function infrastructureTabs(available: InfrastructureTabAvailability): InfrastructureTab[] {
|
|
33
|
+
const tabs: InfrastructureTab[] = []
|
|
34
|
+
if (available.agents) tabs.push('runner-pool')
|
|
35
|
+
if (available.environments) tabs.push('environment', 'shared-stacks')
|
|
36
|
+
if (available.packageRegistries) tabs.push('package-registries')
|
|
37
|
+
return tabs
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The tab to show when the window OPENS. The deep-linked request wins whenever it is available;
|
|
42
|
+
* otherwise fall back to the first tab that is.
|
|
43
|
+
*
|
|
44
|
+
* Whatever tab was showing last time is deliberately NOT consulted: the caller has just asked
|
|
45
|
+
* for `requested`, and honouring a leftover selection over it would silently ignore the deep
|
|
46
|
+
* link. Falling back to `requested` when nothing is available at all keeps the ref naming the
|
|
47
|
+
* thing that was asked for, so the re-pin below can land on it once its probe resolves.
|
|
48
|
+
*/
|
|
49
|
+
export function openInfrastructureTab(
|
|
50
|
+
tabs: readonly InfrastructureTab[],
|
|
51
|
+
requested: InfrastructureTab,
|
|
52
|
+
): InfrastructureTab {
|
|
53
|
+
if (tabs.includes(requested)) return requested
|
|
54
|
+
return tabs[0] ?? requested
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The tab to show after the tab list CHANGES (a probe resolved). Unlike the open-time choice
|
|
59
|
+
* this one protects the user's current position: a tab list that grows under someone reading a
|
|
60
|
+
* tab must not yank them elsewhere.
|
|
61
|
+
*
|
|
62
|
+
* `settled` is what stops the transient single-tab list from stealing the selection. The probes
|
|
63
|
+
* resolve independently, so the list legitimately passes through states missing tabs that are
|
|
64
|
+
* about to appear; falling back to the first tab before the probes have settled would pin the
|
|
65
|
+
* user to whichever one happened to resolve first, and the deep-linked tab would then be
|
|
66
|
+
* REJECTED for being the current one's peer rather than shown.
|
|
67
|
+
*/
|
|
68
|
+
export function repinInfrastructureTab(
|
|
69
|
+
tabs: readonly InfrastructureTab[],
|
|
70
|
+
current: InfrastructureTab,
|
|
71
|
+
requested: InfrastructureTab,
|
|
72
|
+
settled: boolean,
|
|
73
|
+
): InfrastructureTab {
|
|
74
|
+
if (tabs.includes(current)) return current
|
|
75
|
+
if (tabs.includes(requested)) return requested
|
|
76
|
+
if (settled && tabs.length) return tabs[0]!
|
|
77
|
+
return current
|
|
78
|
+
}
|