@cat-factory/app 0.92.2 → 0.94.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.
Files changed (55) hide show
  1. package/app/components/auth/LoginScreen.vue +5 -1
  2. package/app/components/board/AddTaskModal.vue +25 -5
  3. package/app/components/board/CreateInitiativeModal.vue +9 -1
  4. package/app/components/board/RecurringPipelineModal.vue +20 -4
  5. package/app/components/bootstrap/BootstrapModal.vue +9 -1
  6. package/app/components/brainstorm/BrainstormWindow.vue +5 -1
  7. package/app/components/clarity/ClarityReviewWindow.vue +5 -1
  8. package/app/components/docs/DocInterviewWindow.vue +209 -0
  9. package/app/components/documents/DocumentTemplatesModal.vue +5 -1
  10. package/app/components/fragments/FragmentLibraryManager.vue +10 -2
  11. package/app/components/github/GitHubPanel.vue +25 -4
  12. package/app/components/layout/BoardSwitcher.vue +10 -1
  13. package/app/components/layout/BoardToolbar.vue +15 -3
  14. package/app/components/layout/GitHubPatBanner.vue +5 -1
  15. package/app/components/layout/ProviderConfigBanner.vue +5 -1
  16. package/app/components/panels/AgentStepDetail.vue +5 -1
  17. package/app/components/panels/InspectorPanel.vue +5 -1
  18. package/app/components/panels/StepRestartControl.vue +10 -2
  19. package/app/components/panels/StepResultViewHost.vue +4 -0
  20. package/app/components/panels/inspector/FrontendConfig.vue +10 -1
  21. package/app/components/panels/inspector/RecurringScheduleSettings.vue +11 -3
  22. package/app/components/pipeline/PipelineBuilder.vue +15 -2
  23. package/app/components/pipeline/PipelineProgress.vue +10 -2
  24. package/app/components/providers/AiPresetMismatchDialog.vue +10 -1
  25. package/app/components/providers/PersonalCredentialModal.vue +18 -2
  26. package/app/components/requirements/RequirementsReviewWindow.vue +5 -1
  27. package/app/components/sandbox/SandboxPanel.vue +10 -1
  28. package/app/components/settings/InfraHandlersConfigurator.vue +5 -1
  29. package/app/components/settings/InfrastructureWindow.vue +18 -2
  30. package/app/components/settings/IssueTrackerPanel.vue +20 -4
  31. package/app/components/settings/LocalModelEndpointsPanel.vue +5 -1
  32. package/app/components/settings/ModelConfigurationPanel.vue +20 -3
  33. package/app/components/settings/ProviderConnectionTab.vue +5 -1
  34. package/app/components/settings/SharedStacksPanel.vue +363 -0
  35. package/app/components/spec/ServiceSpecWindow.vue +15 -3
  36. package/app/components/testing/TestReportWindow.vue +10 -2
  37. package/app/composables/api/docInterview.ts +36 -0
  38. package/app/composables/api/sharedStacks.ts +42 -0
  39. package/app/composables/useApi.ts +4 -0
  40. package/app/composables/useWorkspaceStream.ts +5 -0
  41. package/app/stores/docInterview.ts +89 -0
  42. package/app/stores/sharedStacks.ts +65 -0
  43. package/app/stores/workspace.ts +3 -0
  44. package/app/types/doc-interview.ts +9 -0
  45. package/app/types/domain.ts +1 -0
  46. package/app/types/sharedStacks.ts +8 -0
  47. package/i18n/locales/en.json +62 -0
  48. package/i18n/locales/es.json +62 -0
  49. package/i18n/locales/fr.json +62 -0
  50. package/i18n/locales/he.json +62 -0
  51. package/i18n/locales/ja.json +62 -0
  52. package/i18n/locales/pl.json +62 -0
  53. package/i18n/locales/tr.json +62 -0
  54. package/i18n/locales/uk.json +62 -0
  55. package/package.json +5 -5
@@ -0,0 +1,363 @@
1
+ <script setup lang="ts">
2
+ // Shared stacks — long-lived compose infra (e.g. acme-shared-services: MySQL / Postgres /
3
+ // Valkey / RabbitMQ / Kafka / ES / Mailpit / Envoy) brought up ONCE per workspace and reused
4
+ // across runs + PRs. A per-PR consumer environment attaches to a stack's managed network. CRUD
5
+ // works on every backend; the bring-up (Start) / teardown (Stop) drive a host Docker daemon, so
6
+ // they succeed only on the local facade (elsewhere the backend returns a clear error surfaced as
7
+ // a toast). Renders inline inside the Infrastructure window's "Shared stacks" tab.
8
+ import { computed, reactive, ref } from 'vue'
9
+ import type { SharedStack, SharedStackStatus } from '~/types/sharedStacks'
10
+
11
+ const { t } = useI18n()
12
+ const store = useSharedStacksStore()
13
+ const toast = useToast()
14
+ const { confirmAction, toastDone } = useConfirmAction()
15
+
16
+ const stacks = computed(() => store.stacks)
17
+ const busyId = ref<string | null>(null)
18
+ const saving = ref(false)
19
+ // null ⇒ the form is in "add" mode; a stack id ⇒ editing that stack's definition in place.
20
+ const editingId = ref<string | null>(null)
21
+
22
+ const form = reactive({
23
+ name: '',
24
+ cloneUrl: '',
25
+ gitRef: '',
26
+ composeFiles: '',
27
+ composeProfiles: '',
28
+ managedNetworks: '',
29
+ allowHostCommands: false,
30
+ })
31
+
32
+ /** Status → badge colour. */
33
+ const STATUS_COLOR: Record<SharedStackStatus, 'neutral' | 'warning' | 'success' | 'error'> = {
34
+ stopped: 'neutral',
35
+ starting: 'warning',
36
+ running: 'success',
37
+ failed: 'error',
38
+ }
39
+
40
+ // Status → catalog key as an EXHAUSTIVE Record over the enum (not a runtime-assembled key), so a
41
+ // new SharedStackStatus fails the typecheck on this map instead of leaking a raw key into the badge.
42
+ const STATUS_LABEL_KEYS: Record<SharedStackStatus, string> = {
43
+ stopped: 'settings.sharedStacks.status.stopped',
44
+ starting: 'settings.sharedStacks.status.starting',
45
+ running: 'settings.sharedStacks.status.running',
46
+ failed: 'settings.sharedStacks.status.failed',
47
+ }
48
+
49
+ function statusLabel(status: SharedStackStatus): string {
50
+ return t(STATUS_LABEL_KEYS[status])
51
+ }
52
+
53
+ /** A running/starting stack cannot be reconfigured (the backend refuses) — edit is stopped/failed only. */
54
+ function canEdit(stack: SharedStack): boolean {
55
+ return stack.status !== 'running' && stack.status !== 'starting'
56
+ }
57
+
58
+ /** Split a comma/whitespace-separated field into trimmed non-empty tokens. */
59
+ function tokens(value: string): string[] {
60
+ return value
61
+ .split(/[\s,]+/)
62
+ .map((s) => s.trim())
63
+ .filter(Boolean)
64
+ }
65
+
66
+ const canSave = computed(
67
+ () => form.name.trim() && form.cloneUrl.trim() && tokens(form.composeFiles).length > 0,
68
+ )
69
+
70
+ function resetForm() {
71
+ editingId.value = null
72
+ form.name = ''
73
+ form.cloneUrl = ''
74
+ form.gitRef = ''
75
+ form.composeFiles = ''
76
+ form.composeProfiles = ''
77
+ form.managedNetworks = ''
78
+ form.allowHostCommands = false
79
+ }
80
+
81
+ /** Load a stack's definition into the form for in-place editing. */
82
+ function startEdit(stack: SharedStack) {
83
+ editingId.value = stack.id
84
+ form.name = stack.name
85
+ form.cloneUrl = stack.cloneUrl
86
+ form.gitRef = stack.gitRef ?? ''
87
+ form.composeFiles = stack.composeFiles.join(', ')
88
+ form.composeProfiles = stack.composeProfiles.join(', ')
89
+ form.managedNetworks = stack.managedNetworks.join(', ')
90
+ form.allowHostCommands = stack.allowHostCommands
91
+ }
92
+
93
+ function notifyError(title: string, e: unknown) {
94
+ toast.add({
95
+ title,
96
+ description: e instanceof Error ? e.message : String(e),
97
+ icon: 'i-lucide-triangle-alert',
98
+ color: 'error',
99
+ })
100
+ }
101
+
102
+ /** Create a new stack, or save edits to the one being edited (same form, mode toggled by `editingId`). */
103
+ async function saveStack() {
104
+ saving.value = true
105
+ const editing = editingId.value
106
+ const payload = {
107
+ name: form.name.trim(),
108
+ cloneUrl: form.cloneUrl.trim(),
109
+ ...(form.gitRef.trim() ? { gitRef: form.gitRef.trim() } : {}),
110
+ composeFiles: tokens(form.composeFiles),
111
+ composeProfiles: tokens(form.composeProfiles),
112
+ managedNetworks: tokens(form.managedNetworks),
113
+ allowHostCommands: form.allowHostCommands,
114
+ }
115
+ try {
116
+ if (editing) {
117
+ await store.update(editing, { ...payload, gitRef: form.gitRef.trim() || null })
118
+ } else {
119
+ await store.create(payload)
120
+ }
121
+ resetForm()
122
+ toast.add({
123
+ title: t(
124
+ editing ? 'settings.sharedStacks.toast.updated' : 'settings.sharedStacks.toast.created',
125
+ ),
126
+ icon: 'i-lucide-check',
127
+ color: 'success',
128
+ })
129
+ } catch (e) {
130
+ notifyError(
131
+ t(
132
+ editing
133
+ ? 'settings.sharedStacks.toast.updateFailed'
134
+ : 'settings.sharedStacks.toast.createFailed',
135
+ ),
136
+ e,
137
+ )
138
+ } finally {
139
+ saving.value = false
140
+ }
141
+ }
142
+
143
+ async function start(stack: SharedStack) {
144
+ busyId.value = stack.id
145
+ try {
146
+ // ensureUp resolves 200 even on a FAILED bring-up (the record carries status/lastError), so
147
+ // surface that as an error toast too — not only a thrown transport/unavailable error.
148
+ const updated = await store.ensureUp(stack.id)
149
+ if (updated.status === 'failed') {
150
+ notifyError(t('settings.sharedStacks.toast.startFailed'), updated.lastError ?? '')
151
+ }
152
+ } catch (e) {
153
+ notifyError(t('settings.sharedStacks.toast.startFailed'), e)
154
+ } finally {
155
+ busyId.value = null
156
+ }
157
+ }
158
+
159
+ async function stop(stack: SharedStack) {
160
+ busyId.value = stack.id
161
+ try {
162
+ await store.teardown(stack.id)
163
+ } catch (e) {
164
+ notifyError(t('settings.sharedStacks.toast.stopFailed'), e)
165
+ } finally {
166
+ busyId.value = null
167
+ }
168
+ }
169
+
170
+ async function remove(stack: SharedStack) {
171
+ const noun = t('settings.sharedStacks.stackNoun')
172
+ if (!(await confirmAction('remove', noun))) return
173
+ busyId.value = stack.id
174
+ try {
175
+ await store.remove(stack.id)
176
+ toastDone('remove', noun)
177
+ } catch (e) {
178
+ notifyError(t('settings.sharedStacks.toast.removeFailed'), e)
179
+ } finally {
180
+ busyId.value = null
181
+ }
182
+ }
183
+ </script>
184
+
185
+ <template>
186
+ <div class="space-y-4" data-testid="shared-stacks-panel">
187
+ <p class="text-sm text-slate-400">{{ t('settings.sharedStacks.intro') }}</p>
188
+
189
+ <section v-if="stacks.length" class="space-y-2 rounded-lg border border-slate-700 p-3">
190
+ <h3 class="text-sm font-semibold">{{ t('settings.sharedStacks.list.heading') }}</h3>
191
+ <div
192
+ v-for="stack in stacks"
193
+ :key="stack.id"
194
+ class="space-y-2 rounded-md border border-slate-800 px-3 py-2"
195
+ :data-testid="`shared-stack-${stack.id}`"
196
+ >
197
+ <div class="flex items-center justify-between gap-2">
198
+ <div class="min-w-0 space-y-1">
199
+ <div class="flex items-center gap-2">
200
+ <span class="text-sm font-medium">{{ stack.name }}</span>
201
+ <UBadge :color="STATUS_COLOR[stack.status]" variant="soft" size="sm">
202
+ {{ statusLabel(stack.status) }}
203
+ </UBadge>
204
+ </div>
205
+ <p class="truncate text-[11px] text-slate-500">{{ stack.cloneUrl }}</p>
206
+ <div v-if="stack.managedNetworks.length" class="flex flex-wrap gap-1">
207
+ <UBadge
208
+ v-for="net in stack.managedNetworks"
209
+ :key="net"
210
+ color="neutral"
211
+ variant="soft"
212
+ size="sm"
213
+ >
214
+ {{ net }}
215
+ </UBadge>
216
+ </div>
217
+ </div>
218
+ <div class="flex shrink-0 items-center gap-1">
219
+ <UButton
220
+ v-if="stack.status !== 'running'"
221
+ icon="i-lucide-play"
222
+ size="sm"
223
+ variant="soft"
224
+ :loading="busyId === stack.id"
225
+ :data-testid="`shared-stack-start-${stack.id}`"
226
+ @click="start(stack)"
227
+ >
228
+ {{ t('settings.sharedStacks.list.start') }}
229
+ </UButton>
230
+ <UButton
231
+ v-else
232
+ icon="i-lucide-square"
233
+ size="sm"
234
+ variant="soft"
235
+ color="warning"
236
+ :loading="busyId === stack.id"
237
+ :data-testid="`shared-stack-stop-${stack.id}`"
238
+ @click="stop(stack)"
239
+ >
240
+ {{ t('settings.sharedStacks.list.stop') }}
241
+ </UButton>
242
+ <UButton
243
+ v-if="canEdit(stack)"
244
+ color="neutral"
245
+ variant="ghost"
246
+ icon="i-lucide-pencil"
247
+ size="sm"
248
+ :data-testid="`shared-stack-edit-${stack.id}`"
249
+ :aria-label="t('settings.sharedStacks.list.edit')"
250
+ @click="startEdit(stack)"
251
+ />
252
+ <UButton
253
+ color="error"
254
+ variant="ghost"
255
+ icon="i-lucide-trash-2"
256
+ size="sm"
257
+ :loading="busyId === stack.id"
258
+ :data-testid="`shared-stack-delete-${stack.id}`"
259
+ :aria-label="t('settings.sharedStacks.list.remove')"
260
+ @click="remove(stack)"
261
+ />
262
+ </div>
263
+ </div>
264
+ <p v-if="stack.lastError" class="text-[11px] text-rose-400">{{ stack.lastError }}</p>
265
+ </div>
266
+ </section>
267
+
268
+ <section
269
+ class="space-y-3 rounded-lg border border-slate-700 p-3"
270
+ data-testid="shared-stack-form"
271
+ >
272
+ <h3 class="text-sm font-semibold">
273
+ {{
274
+ t(editingId ? 'settings.sharedStacks.edit.heading' : 'settings.sharedStacks.add.heading')
275
+ }}
276
+ </h3>
277
+
278
+ <UFormField :label="t('settings.sharedStacks.add.name')">
279
+ <UInput v-model="form.name" class="w-full" data-testid="shared-stack-name" />
280
+ </UFormField>
281
+
282
+ <UFormField
283
+ :label="t('settings.sharedStacks.add.cloneUrl')"
284
+ :help="t('settings.sharedStacks.add.cloneUrlHelp')"
285
+ >
286
+ <UInput
287
+ v-model="form.cloneUrl"
288
+ placeholder="https://github.com/acme/acme-shared-services.git"
289
+ class="w-full"
290
+ data-testid="shared-stack-clone-url"
291
+ />
292
+ </UFormField>
293
+
294
+ <UFormField :label="t('settings.sharedStacks.add.gitRef')">
295
+ <UInput
296
+ v-model="form.gitRef"
297
+ placeholder="main"
298
+ class="w-full"
299
+ data-testid="shared-stack-git-ref"
300
+ />
301
+ </UFormField>
302
+
303
+ <UFormField
304
+ :label="t('settings.sharedStacks.add.composeFiles')"
305
+ :help="t('settings.sharedStacks.add.composeFilesHelp')"
306
+ >
307
+ <UInput
308
+ v-model="form.composeFiles"
309
+ placeholder="docker-compose.yml, docker-compose.override.yml"
310
+ class="w-full"
311
+ data-testid="shared-stack-compose-files"
312
+ />
313
+ </UFormField>
314
+
315
+ <UFormField :label="t('settings.sharedStacks.add.composeProfiles')">
316
+ <UInput
317
+ v-model="form.composeProfiles"
318
+ placeholder="backends, peer"
319
+ class="w-full"
320
+ data-testid="shared-stack-profiles"
321
+ />
322
+ </UFormField>
323
+
324
+ <UFormField
325
+ :label="t('settings.sharedStacks.add.managedNetworks')"
326
+ :help="t('settings.sharedStacks.add.managedNetworksHelp')"
327
+ >
328
+ <UInput
329
+ v-model="form.managedNetworks"
330
+ placeholder="acme-net"
331
+ class="w-full"
332
+ data-testid="shared-stack-networks"
333
+ />
334
+ </UFormField>
335
+
336
+ <UCheckbox
337
+ v-model="form.allowHostCommands"
338
+ :label="t('settings.sharedStacks.add.allowHostCommands')"
339
+ data-testid="shared-stack-allow-host-commands"
340
+ />
341
+
342
+ <div class="flex items-center gap-2">
343
+ <UButton
344
+ :loading="saving"
345
+ :disabled="!canSave"
346
+ data-testid="shared-stack-save"
347
+ @click="saveStack"
348
+ >
349
+ {{ t(editingId ? 'settings.sharedStacks.edit.save' : 'settings.sharedStacks.add.save') }}
350
+ </UButton>
351
+ <UButton
352
+ v-if="editingId"
353
+ color="neutral"
354
+ variant="ghost"
355
+ data-testid="shared-stack-cancel-edit"
356
+ @click="resetForm"
357
+ >
358
+ {{ t('settings.sharedStacks.edit.cancel') }}
359
+ </UButton>
360
+ </div>
361
+ </section>
362
+ </div>
363
+ </template>
@@ -147,7 +147,11 @@ function kindLabel(item: RequirementItem): string {
147
147
  :variant="mode === 'structured' ? 'soft' : 'ghost'"
148
148
  size="xs"
149
149
  icon="i-lucide-list-tree"
150
- @click="mode = 'structured'"
150
+ @click="
151
+ () => {
152
+ mode = 'structured'
153
+ }
154
+ "
151
155
  >
152
156
  {{ t('spec.mode.structured') }}
153
157
  </UButton>
@@ -158,7 +162,11 @@ function kindLabel(item: RequirementItem): string {
158
162
  icon="i-lucide-square-check-big"
159
163
  :disabled="!hasGherkin"
160
164
  :title="hasGherkin ? t('spec.mode.gherkinTooltip') : t('spec.mode.gherkinNone')"
161
- @click="mode = 'gherkin'"
165
+ @click="
166
+ () => {
167
+ mode = 'gherkin'
168
+ }
169
+ "
162
170
  >
163
171
  {{ t('spec.mode.gherkin') }}
164
172
  </UButton>
@@ -210,7 +218,11 @@ function kindLabel(item: RequirementItem): string {
210
218
  :variant="selected === null ? 'soft' : 'ghost'"
211
219
  size="xs"
212
220
  icon="i-lucide-info"
213
- @click="selected = null"
221
+ @click="
222
+ () => {
223
+ selected = null
224
+ }
225
+ "
214
226
  >
215
227
  {{ t('spec.overview') }}
216
228
  </UButton>
@@ -444,7 +444,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
444
444
  size="xs"
445
445
  class="mt-1.5"
446
446
  data-testid="tester-infra-setup-logs-toggle"
447
- @click="showInfraSetupLogs = !showInfraSetupLogs"
447
+ @click="
448
+ () => {
449
+ showInfraSetupLogs = !showInfraSetupLogs
450
+ }
451
+ "
448
452
  >
449
453
  {{
450
454
  showInfraSetupLogs
@@ -480,7 +484,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
480
484
  variant="ghost"
481
485
  size="xs"
482
486
  data-testid="tester-infra-attempts-toggle"
483
- @click="showProvisioning = !showProvisioning"
487
+ @click="
488
+ () => {
489
+ showProvisioning = !showProvisioning
490
+ }
491
+ "
484
492
  >
485
493
  {{
486
494
  showProvisioning
@@ -0,0 +1,36 @@
1
+ import {
2
+ answerDocInterviewContract,
3
+ continueDocInterviewContract,
4
+ getDocInterviewContract,
5
+ proceedDocInterviewContract,
6
+ } from '@cat-factory/contracts'
7
+ import type { ApiContext } from './context'
8
+
9
+ /** Interactive document-interview session (WS5): load + answer / continue / proceed. */
10
+ export function docInterviewApi({ send, ws }: ApiContext) {
11
+ return {
12
+ // The interview window's load path: the session anchored to a board block (or null).
13
+ getDocInterview: (workspaceId: string, blockId: string) =>
14
+ send(getDocInterviewContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
15
+
16
+ // Answer one interview question (no run resume), then continue (interviewer re-runs, may
17
+ // ask more) or proceed (skip remaining, synthesize the brief and advance to the writer).
18
+ answerDocInterview: (
19
+ workspaceId: string,
20
+ blockId: string,
21
+ questionId: string,
22
+ answer: string,
23
+ ) =>
24
+ send(answerDocInterviewContract, {
25
+ pathPrefix: ws(workspaceId),
26
+ pathParams: { blockId },
27
+ body: { questionId, answer },
28
+ }),
29
+
30
+ continueDocInterview: (workspaceId: string, blockId: string) =>
31
+ send(continueDocInterviewContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
32
+
33
+ proceedDocInterview: (workspaceId: string, blockId: string) =>
34
+ send(proceedDocInterviewContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
35
+ }
36
+ }
@@ -0,0 +1,42 @@
1
+ import {
2
+ createSharedStackContract,
3
+ deleteSharedStackContract,
4
+ ensureSharedStackUpContract,
5
+ listSharedStacksContract,
6
+ teardownSharedStackContract,
7
+ updateSharedStackContract,
8
+ } from '@cat-factory/contracts'
9
+ import type { UpdateSharedStackInput } from '~/types/sharedStacks'
10
+ import type { SendParams } from './client'
11
+ import type { ApiContext } from './context'
12
+
13
+ // The create body is typed from the contract's INPUT shape so the valibot-defaulted array fields
14
+ // (profiles, envFiles, managedNetworks, setupSteps, allowHostCommands) stay optional for callers.
15
+ type CreateSharedStackBody = NonNullable<SendParams<typeof createSharedStackContract>['body']>
16
+
17
+ /** A workspace's shared stacks: CRUD plus the ensure-up / teardown lifecycle actions. */
18
+ export function sharedStacksApi({ send, ws }: ApiContext) {
19
+ return {
20
+ listSharedStacks: (workspaceId: string) =>
21
+ send(listSharedStacksContract, { pathPrefix: ws(workspaceId) }),
22
+
23
+ createSharedStack: (workspaceId: string, body: CreateSharedStackBody) =>
24
+ send(createSharedStackContract, { pathPrefix: ws(workspaceId), body }),
25
+
26
+ updateSharedStack: (workspaceId: string, stackId: string, body: UpdateSharedStackInput) =>
27
+ send(updateSharedStackContract, {
28
+ pathPrefix: ws(workspaceId),
29
+ pathParams: { stackId },
30
+ body,
31
+ }),
32
+
33
+ deleteSharedStack: (workspaceId: string, stackId: string) =>
34
+ send(deleteSharedStackContract, { pathPrefix: ws(workspaceId), pathParams: { stackId } }),
35
+
36
+ ensureSharedStackUp: (workspaceId: string, stackId: string) =>
37
+ send(ensureSharedStackUpContract, { pathPrefix: ws(workspaceId), pathParams: { stackId } }),
38
+
39
+ teardownSharedStack: (workspaceId: string, stackId: string) =>
40
+ send(teardownSharedStackContract, { pathPrefix: ws(workspaceId), pathParams: { stackId } }),
41
+ }
42
+ }
@@ -14,6 +14,7 @@ import { humanReviewApi } from './api/humanReview'
14
14
  import { humanTestApi } from './api/humanTest'
15
15
  import { infraHandlersApi } from './api/infraHandlers'
16
16
  import { initiativeApi } from './api/initiative'
17
+ import { docInterviewApi } from './api/docInterview'
17
18
  import { visualConfirmApi } from './api/visualConfirm'
18
19
  import { kaizenApi } from './api/kaizen'
19
20
  import { localSettingsApi } from './api/localSettings'
@@ -21,6 +22,7 @@ import { modelsApi } from './api/models'
21
22
  import { notificationsApi } from './api/notifications'
22
23
  import { packageRegistriesApi } from './api/packageRegistries'
23
24
  import { presetsApi } from './api/presets'
25
+ import { sharedStacksApi } from './api/sharedStacks'
24
26
  import { providerConnectionsApi } from './api/providerConnections'
25
27
  import { provisioningLogsApi } from './api/provisioningLogs'
26
28
  import { recurringApi } from './api/recurring'
@@ -111,9 +113,11 @@ export function useApi() {
111
113
  ...specApi(ctx),
112
114
  ...notificationsApi(ctx),
113
115
  ...presetsApi(ctx),
116
+ ...sharedStacksApi(ctx),
114
117
  ...providerConnectionsApi(ctx),
115
118
  ...infraHandlersApi(ctx),
116
119
  ...initiativeApi(ctx),
120
+ ...docInterviewApi(ctx),
117
121
  ...provisioningLogsApi(ctx),
118
122
  ...releaseHealthApi(ctx),
119
123
  ...packageRegistriesApi(ctx),
@@ -26,6 +26,7 @@ export function useWorkspaceStream() {
26
26
  const brainstorm = useBrainstormStore()
27
27
  const kaizen = useKaizenStore()
28
28
  const initiatives = useInitiativesStore()
29
+ const docInterview = useDocInterviewStore()
29
30
  const api = useApi()
30
31
  const apiBase = useRuntimeConfig().public.apiBase
31
32
 
@@ -108,6 +109,10 @@ export function useWorkspaceStream() {
108
109
  // An initiative changed (created, plan ingested, an item settled) — patch the cache
109
110
  // so an open tracker window / the board card reflects the transition live.
110
111
  initiatives.upsert(event.initiative)
112
+ } else if (event.type === 'docInterview') {
113
+ // The interactive document interview advanced (a fresh batch of questions, an answer, or
114
+ // convergence) — patch the cache so an open interview window reflects it live.
115
+ docInterview.upsert(event.session)
111
116
  }
112
117
  }
113
118
 
@@ -0,0 +1,89 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { DocInterviewSession } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * Interactive document-interview sessions (WS5), keyed by their anchor BLOCK id. Loaded on
8
+ * demand when the interview window opens (`load`) and patched live from `docInterview` stream
9
+ * events (`upsert`), so an open window follows the interview as the interviewer asks / converges.
10
+ * Not carried in the workspace snapshot (a transient per-run gate, unlike initiatives).
11
+ * Per-workspace; nothing is persisted client-side.
12
+ */
13
+ export const useDocInterviewStore = defineStore('docInterview', () => {
14
+ const api = useApi()
15
+ const workspace = useWorkspaceStore()
16
+
17
+ /** The sessions keyed by their anchor block id. */
18
+ const byBlock = ref<Record<string, DocInterviewSession>>({})
19
+ /** True while a window action (continue/proceed) is resuming the run. */
20
+ const resuming = ref(false)
21
+
22
+ function forBlock(blockId: string): DocInterviewSession | null {
23
+ return byBlock.value[blockId] ?? null
24
+ }
25
+
26
+ /** Patch from a live `docInterview` stream event or a call response (newest write wins). */
27
+ function upsert(session: DocInterviewSession) {
28
+ const existing = byBlock.value[session.blockId]
29
+ if (existing && existing.updatedAt > session.updatedAt) return
30
+ byBlock.value = { ...byBlock.value, [session.blockId]: session }
31
+ }
32
+
33
+ /** Re-fetch one block's session (the interview window's load path). */
34
+ async function load(blockId: string) {
35
+ if (!workspace.workspaceId) return
36
+ const session = await api.getDocInterview(workspace.workspaceId, blockId)
37
+ if (session) upsert(session)
38
+ }
39
+
40
+ /** Record the human's answer to one pending interview question (no run resume). */
41
+ async function answerQuestion(blockId: string, questionId: string, answer: string) {
42
+ if (!workspace.workspaceId) throw new Error('No active workspace')
43
+ const updated = await api.answerDocInterview(workspace.workspaceId, blockId, questionId, answer)
44
+ upsert(updated)
45
+ return updated
46
+ }
47
+
48
+ /** Submit the answers and resume the interview (the interviewer re-runs, may ask more). */
49
+ async function continueInterview(blockId: string) {
50
+ if (!workspace.workspaceId) throw new Error('No active workspace')
51
+ resuming.value = true
52
+ try {
53
+ const updated = await api.continueDocInterview(workspace.workspaceId, blockId)
54
+ upsert(updated)
55
+ return updated
56
+ } finally {
57
+ resuming.value = false
58
+ }
59
+ }
60
+
61
+ /** Skip remaining questions: the interviewer converges and the run advances to the writer. */
62
+ async function proceedInterview(blockId: string) {
63
+ if (!workspace.workspaceId) throw new Error('No active workspace')
64
+ resuming.value = true
65
+ try {
66
+ const updated = await api.proceedDocInterview(workspace.workspaceId, blockId)
67
+ upsert(updated)
68
+ return updated
69
+ } finally {
70
+ resuming.value = false
71
+ }
72
+ }
73
+
74
+ function reset() {
75
+ byBlock.value = {}
76
+ }
77
+
78
+ return {
79
+ byBlock,
80
+ resuming,
81
+ forBlock,
82
+ upsert,
83
+ load,
84
+ answerQuestion,
85
+ continueInterview,
86
+ proceedInterview,
87
+ reset,
88
+ }
89
+ })