@cat-factory/app 0.6.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/LICENSE +21 -0
- package/README.md +88 -0
- package/app/app.config.ts +8 -0
- package/app/app.vue +11 -0
- package/app/assets/css/main.css +100 -0
- package/app/components/auth/AuthGate.vue +24 -0
- package/app/components/auth/LoginScreen.vue +143 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AddTaskModal.vue +444 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +183 -0
- package/app/components/board/ContextPicker.vue +367 -0
- package/app/components/board/RecurringPipelineModal.vue +219 -0
- package/app/components/board/TaskDependencyEdges.vue +132 -0
- package/app/components/board/nodes/AgentChip.vue +59 -0
- package/app/components/board/nodes/BlockNode.vue +433 -0
- package/app/components/board/nodes/DecisionBadge.vue +27 -0
- package/app/components/board/nodes/DraggableTask.vue +48 -0
- package/app/components/board/nodes/ModuleFrame.vue +97 -0
- package/app/components/board/nodes/TaskCard.vue +359 -0
- package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/clarity/ClarityReviewWindow.vue +611 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
- package/app/components/documents/DocumentImportModal.vue +161 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
- package/app/components/documents/SpawnPreviewModal.vue +161 -0
- package/app/components/documents/TaskContextDocs.vue +83 -0
- package/app/components/focus/BlockFocusView.vue +171 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/gates/GateResultView.vue +282 -0
- package/app/components/github/AddServiceFromRepoModal.vue +354 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubOnboarding.vue +45 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/github/RepoTreeBrowser.vue +171 -0
- package/app/components/layout/AccountTeamSettings.vue +237 -0
- package/app/components/layout/BoardSwitcher.vue +280 -0
- package/app/components/layout/BoardToolbar.vue +156 -0
- package/app/components/layout/CommandBar.vue +336 -0
- package/app/components/layout/GitHubPatBanner.vue +73 -0
- package/app/components/layout/NotificationsInbox.vue +175 -0
- package/app/components/layout/SideBar.vue +314 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/observability/StepMetricsBar.vue +102 -0
- package/app/components/palettes/AgentPalette.vue +86 -0
- package/app/components/panels/AgentStepDetail.vue +737 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +465 -0
- package/app/components/panels/ObservabilityPanel.vue +351 -0
- package/app/components/panels/StepMetadataCard.vue +253 -0
- package/app/components/panels/StepRestartControl.vue +90 -0
- package/app/components/panels/StepResultViewHost.vue +40 -0
- package/app/components/panels/StepTestReport.vue +84 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
- package/app/components/panels/inspector/ServiceFragments.vue +82 -0
- package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
- package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
- package/app/components/panels/inspector/TaskExecution.vue +364 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
- package/app/components/panels/inspector/TaskStructure.vue +96 -0
- package/app/components/pipeline/AgentKindIcon.vue +30 -0
- package/app/components/pipeline/IterationCapPrompt.vue +70 -0
- package/app/components/pipeline/PipelineBuilder.vue +817 -0
- package/app/components/pipeline/PipelineProgress.vue +484 -0
- package/app/components/providers/ApiKeysSection.vue +273 -0
- package/app/components/providers/PersonalCredentialModal.vue +128 -0
- package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
- package/app/components/providers/VendorCredentialsModal.vue +197 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
- package/app/components/settings/DatadogPanel.vue +213 -0
- package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
- package/app/components/settings/MergeThresholdsPanel.vue +378 -0
- package/app/components/settings/ModelDefaultsPanel.vue +250 -0
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
- package/app/components/slack/SlackPanel.vue +299 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +207 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
- package/app/components/testing/TestReportWindow.vue +404 -0
- package/app/composables/api/accounts.ts +81 -0
- package/app/composables/api/auth.ts +45 -0
- package/app/composables/api/board.ts +101 -0
- package/app/composables/api/bootstrap.ts +62 -0
- package/app/composables/api/context.ts +25 -0
- package/app/composables/api/documents.ts +74 -0
- package/app/composables/api/execution.ts +127 -0
- package/app/composables/api/fragments.ts +71 -0
- package/app/composables/api/github.ts +131 -0
- package/app/composables/api/models.ts +127 -0
- package/app/composables/api/notifications.ts +23 -0
- package/app/composables/api/presets.ts +29 -0
- package/app/composables/api/recurring.ts +68 -0
- package/app/composables/api/releaseHealth.ts +43 -0
- package/app/composables/api/reviews.ts +146 -0
- package/app/composables/api/slack.ts +54 -0
- package/app/composables/api/tasks.ts +72 -0
- package/app/composables/api/workspaces.ts +36 -0
- package/app/composables/useApi.ts +89 -0
- package/app/composables/useBlockDrag.ts +90 -0
- package/app/composables/useBlockQueries.ts +154 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useContextLinking.ts +65 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useFrameResize.ts +54 -0
- package/app/composables/useResultView.ts +48 -0
- package/app/composables/useReviewStage.ts +40 -0
- package/app/composables/useSemanticZoom.ts +31 -0
- package/app/composables/useStepApproval.ts +233 -0
- package/app/composables/useStepProse.ts +78 -0
- package/app/composables/useStepTimer.ts +63 -0
- package/app/composables/useTaskExpansion.ts +92 -0
- package/app/composables/useWorkspaceStream.ts +155 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +141 -0
- package/app/stores/accounts.ts +152 -0
- package/app/stores/agentConfig.ts +35 -0
- package/app/stores/agentRuns.ts +122 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/apiKeys.ts +108 -0
- package/app/stores/auth.ts +166 -0
- package/app/stores/board.spec.ts +205 -0
- package/app/stores/board.ts +286 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/clarity.ts +196 -0
- package/app/stores/consensus.ts +60 -0
- package/app/stores/documents.ts +176 -0
- package/app/stores/execution.ts +273 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +305 -0
- package/app/stores/localModels.ts +51 -0
- package/app/stores/mergePresets.ts +58 -0
- package/app/stores/modelDefaults.ts +76 -0
- package/app/stores/models.ts +134 -0
- package/app/stores/notifications.ts +70 -0
- package/app/stores/observability.ts +144 -0
- package/app/stores/personalSubscriptions.ts +215 -0
- package/app/stores/pipelines.ts +327 -0
- package/app/stores/recurringPipelines.ts +112 -0
- package/app/stores/releaseHealth.ts +75 -0
- package/app/stores/requirements.spec.ts +94 -0
- package/app/stores/requirements.ts +208 -0
- package/app/stores/serviceFragmentDefaults.ts +29 -0
- package/app/stores/services.ts +87 -0
- package/app/stores/slack.ts +142 -0
- package/app/stores/taskExpansion.ts +36 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +176 -0
- package/app/stores/tracker.ts +27 -0
- package/app/stores/ui.ts +434 -0
- package/app/stores/vendorCredentials.ts +54 -0
- package/app/stores/workspace.ts +215 -0
- package/app/stores/workspaceSettings.ts +36 -0
- package/app/types/accounts.ts +77 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/clarity.ts +59 -0
- package/app/types/consensus.ts +91 -0
- package/app/types/documents.ts +104 -0
- package/app/types/domain.ts +495 -0
- package/app/types/execution.ts +383 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +173 -0
- package/app/types/localModels.ts +73 -0
- package/app/types/merge.ts +71 -0
- package/app/types/models.ts +157 -0
- package/app/types/notifications.ts +74 -0
- package/app/types/recurring.ts +69 -0
- package/app/types/releaseHealth.ts +31 -0
- package/app/types/requirements.ts +61 -0
- package/app/types/services.ts +27 -0
- package/app/types/slack.ts +57 -0
- package/app/types/tasks.ts +82 -0
- package/app/types/tracker.ts +18 -0
- package/app/utils/agentOutput.spec.ts +128 -0
- package/app/utils/agentOutput.ts +173 -0
- package/app/utils/catalog.spec.ts +112 -0
- package/app/utils/catalog.ts +455 -0
- package/app/utils/dnd.ts +29 -0
- package/app/utils/observability.ts +52 -0
- package/app/utils/pipelineRender.ts +151 -0
- package/nuxt.config.ts +55 -0
- package/package.json +45 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
CreateScheduleInput,
|
|
5
|
+
PipelineSchedule,
|
|
6
|
+
ScheduleRun,
|
|
7
|
+
UpdateScheduleInput,
|
|
8
|
+
} from '~/types/recurring'
|
|
9
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
10
|
+
import { useBoardStore } from '~/stores/board'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The workspace's recurring pipelines — schedules that re-run a pipeline against a
|
|
14
|
+
* service on a cadence. Hydrated from the workspace snapshot; created from a button
|
|
15
|
+
* on the service frame and managed from the inspector. Run history is fetched
|
|
16
|
+
* lazily per schedule (it is retained only ~1 week so it stays small).
|
|
17
|
+
*/
|
|
18
|
+
export const useRecurringPipelinesStore = defineStore('recurringPipelines', () => {
|
|
19
|
+
const api = useApi()
|
|
20
|
+
const toast = useToast()
|
|
21
|
+
|
|
22
|
+
const schedules = ref<PipelineSchedule[]>([])
|
|
23
|
+
/** Lazily-loaded run history, keyed by schedule id. */
|
|
24
|
+
const runsBySchedule = ref<Record<string, ScheduleRun[]>>({})
|
|
25
|
+
|
|
26
|
+
function hydrate(list: PipelineSchedule[]) {
|
|
27
|
+
schedules.value = [...list].sort((a, b) => a.createdAt - b.createdAt)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Schedules grouped by the service frame they live in (for board badges). */
|
|
31
|
+
const byFrame = computed<Record<string, PipelineSchedule[]>>(() => {
|
|
32
|
+
const map: Record<string, PipelineSchedule[]> = {}
|
|
33
|
+
for (const s of schedules.value) (map[s.frameId] ??= []).push(s)
|
|
34
|
+
return map
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** The schedule whose reused block is `blockId`, if any. */
|
|
38
|
+
function byBlock(blockId: string): PipelineSchedule | undefined {
|
|
39
|
+
return schedules.value.find((s) => s.blockId === blockId)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function create(input: CreateScheduleInput) {
|
|
43
|
+
const ws = useWorkspaceStore()
|
|
44
|
+
const created = await api.createRecurringPipeline(ws.requireId(), input)
|
|
45
|
+
await ws.refresh()
|
|
46
|
+
return created
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function update(id: string, patch: UpdateScheduleInput) {
|
|
50
|
+
const ws = useWorkspaceStore()
|
|
51
|
+
const updated = await api.updateRecurringPipeline(ws.requireId(), id, patch)
|
|
52
|
+
await ws.refresh()
|
|
53
|
+
return updated
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Delete a recurring pipeline. Deleting the schedule cascades to its reused block
|
|
58
|
+
* + run history server-side, so we hide BOTH immediately (optimistic) and restore
|
|
59
|
+
* them with a toast if the backend rejects the delete.
|
|
60
|
+
*/
|
|
61
|
+
async function remove(id: string) {
|
|
62
|
+
const ws = useWorkspaceStore()
|
|
63
|
+
const board = useBoardStore()
|
|
64
|
+
const sched = schedules.value.find((s) => s.id === id)
|
|
65
|
+
const blockSnap = sched ? board.detach(sched.blockId) : null
|
|
66
|
+
const prevSchedules = schedules.value
|
|
67
|
+
schedules.value = schedules.value.filter((s) => s.id !== id)
|
|
68
|
+
try {
|
|
69
|
+
await api.deleteRecurringPipeline(ws.requireId(), id)
|
|
70
|
+
delete runsBySchedule.value[id]
|
|
71
|
+
await ws.refresh()
|
|
72
|
+
} catch (e) {
|
|
73
|
+
schedules.value = prevSchedules
|
|
74
|
+
if (blockSnap) board.reattach(blockSnap)
|
|
75
|
+
toast.add({
|
|
76
|
+
title: 'Could not delete recurring pipeline',
|
|
77
|
+
description: e instanceof Error ? e.message : String(e),
|
|
78
|
+
icon: 'i-lucide-triangle-alert',
|
|
79
|
+
color: 'error',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function runNow(id: string) {
|
|
85
|
+
const ws = useWorkspaceStore()
|
|
86
|
+
const schedule = await api.runScheduleNow(ws.requireId(), id)
|
|
87
|
+
await loadRuns(id)
|
|
88
|
+
await ws.refresh()
|
|
89
|
+
return schedule
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Fetch (and cache) a schedule's run history for the inspector. */
|
|
93
|
+
async function loadRuns(id: string) {
|
|
94
|
+
const ws = useWorkspaceStore()
|
|
95
|
+
const runs = await api.listScheduleRuns(ws.requireId(), id)
|
|
96
|
+
runsBySchedule.value = { ...runsBySchedule.value, [id]: runs }
|
|
97
|
+
return runs
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
schedules,
|
|
102
|
+
runsBySchedule,
|
|
103
|
+
byFrame,
|
|
104
|
+
byBlock,
|
|
105
|
+
hydrate,
|
|
106
|
+
create,
|
|
107
|
+
update,
|
|
108
|
+
remove,
|
|
109
|
+
runNow,
|
|
110
|
+
loadRuns,
|
|
111
|
+
}
|
|
112
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
DatadogConnectionView,
|
|
5
|
+
ReleaseHealthConfig,
|
|
6
|
+
UpsertDatadogConnectionInput,
|
|
7
|
+
UpsertReleaseHealthConfigInput,
|
|
8
|
+
} from '~/types/releaseHealth'
|
|
9
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The workspace's Datadog post-release-health settings: the (single) connection — keys
|
|
13
|
+
* are write-only, never read back — and the per-block monitor/SLO mappings the
|
|
14
|
+
* `post-release-health` gate reads. Loaded on demand (the settings panel), not from the
|
|
15
|
+
* snapshot, since the secrets never leave the server.
|
|
16
|
+
*/
|
|
17
|
+
export const useReleaseHealthStore = defineStore('releaseHealth', () => {
|
|
18
|
+
const api = useApi()
|
|
19
|
+
|
|
20
|
+
const connection = ref<DatadogConnectionView>({ connected: false, site: null })
|
|
21
|
+
const configs = ref<ReleaseHealthConfig[]>([])
|
|
22
|
+
const loading = ref(false)
|
|
23
|
+
|
|
24
|
+
async function load() {
|
|
25
|
+
const ws = useWorkspaceStore()
|
|
26
|
+
loading.value = true
|
|
27
|
+
try {
|
|
28
|
+
const [conn, list] = await Promise.all([
|
|
29
|
+
api.getDatadogConnection(ws.requireId()),
|
|
30
|
+
api.listReleaseHealthConfigs(ws.requireId()),
|
|
31
|
+
])
|
|
32
|
+
connection.value = conn
|
|
33
|
+
configs.value = list
|
|
34
|
+
} finally {
|
|
35
|
+
loading.value = false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function saveConnection(input: UpsertDatadogConnectionInput) {
|
|
40
|
+
const ws = useWorkspaceStore()
|
|
41
|
+
connection.value = await api.setDatadogConnection(ws.requireId(), input)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function removeConnection() {
|
|
45
|
+
const ws = useWorkspaceStore()
|
|
46
|
+
await api.deleteDatadogConnection(ws.requireId())
|
|
47
|
+
connection.value = { connected: false, site: null }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function saveConfig(blockId: string, input: UpsertReleaseHealthConfigInput) {
|
|
51
|
+
const ws = useWorkspaceStore()
|
|
52
|
+
const saved = await api.upsertReleaseHealthConfig(ws.requireId(), blockId, input)
|
|
53
|
+
const idx = configs.value.findIndex((c) => c.blockId === blockId)
|
|
54
|
+
if (idx >= 0) configs.value[idx] = saved
|
|
55
|
+
else configs.value.push(saved)
|
|
56
|
+
return saved
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function removeConfig(blockId: string) {
|
|
60
|
+
const ws = useWorkspaceStore()
|
|
61
|
+
await api.deleteReleaseHealthConfig(ws.requireId(), blockId)
|
|
62
|
+
configs.value = configs.value.filter((c) => c.blockId !== blockId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
connection,
|
|
67
|
+
configs,
|
|
68
|
+
loading,
|
|
69
|
+
load,
|
|
70
|
+
saveConnection,
|
|
71
|
+
removeConnection,
|
|
72
|
+
saveConfig,
|
|
73
|
+
removeConfig,
|
|
74
|
+
}
|
|
75
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import type { RequirementReview } from '~/types/requirements'
|
|
3
|
+
import { useRequirementsStore } from '~/stores/requirements'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/** Minimal review factory — only the fields the store getters touch. */
|
|
7
|
+
function review(over: Partial<RequirementReview> = {}): RequirementReview {
|
|
8
|
+
return {
|
|
9
|
+
id: 'rr1',
|
|
10
|
+
blockId: 'b1',
|
|
11
|
+
status: 'ready',
|
|
12
|
+
iteration: 1,
|
|
13
|
+
maxIterations: 3,
|
|
14
|
+
items: [],
|
|
15
|
+
incorporatedRequirements: null,
|
|
16
|
+
model: null,
|
|
17
|
+
...over,
|
|
18
|
+
} as RequirementReview
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('requirements store load() loading flag', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
// The store resolves its workspace id from the workspace store at call time.
|
|
24
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('flags the block as loading while the fetch is in flight, then clears it', async () => {
|
|
28
|
+
// A deferred fetch so we can observe the in-flight window before it resolves —
|
|
29
|
+
// this is the race the spinner state guards against (review null + not loading
|
|
30
|
+
// would otherwise render the "no review yet" empty state on first open).
|
|
31
|
+
let resolveFetch!: (r: RequirementReview) => void
|
|
32
|
+
const pending = new Promise<RequirementReview>((res) => {
|
|
33
|
+
resolveFetch = res
|
|
34
|
+
})
|
|
35
|
+
vi.stubGlobal('useApi', () => ({ getRequirementReview: () => pending }))
|
|
36
|
+
|
|
37
|
+
const store = useRequirementsStore()
|
|
38
|
+
expect(store.isLoading('b1')).toBe(false)
|
|
39
|
+
|
|
40
|
+
const loadPromise = store.load('b1')
|
|
41
|
+
// In flight: no review cached yet, but the block is flagged loading.
|
|
42
|
+
expect(store.reviewFor('b1')).toBeNull()
|
|
43
|
+
expect(store.isLoading('b1')).toBe(true)
|
|
44
|
+
|
|
45
|
+
resolveFetch(review())
|
|
46
|
+
await loadPromise
|
|
47
|
+
|
|
48
|
+
expect(store.isLoading('b1')).toBe(false)
|
|
49
|
+
expect(store.reviewFor('b1')?.id).toBe('rr1')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('clears the loading flag even when the fetch rejects', async () => {
|
|
53
|
+
vi.stubGlobal('useApi', () => ({
|
|
54
|
+
getRequirementReview: () => Promise.reject(new Error('503')),
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
const store = useRequirementsStore()
|
|
58
|
+
await store.load('b1')
|
|
59
|
+
|
|
60
|
+
expect(store.isLoading('b1')).toBe(false)
|
|
61
|
+
expect(store.available).toBe(false)
|
|
62
|
+
expect(store.reviewFor('b1')).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('coalesces concurrent load() calls for the same block into one request', async () => {
|
|
66
|
+
// Two callers open at once (the inspector badge watch + the review window). They must
|
|
67
|
+
// share a single in-flight request, not each fetch their own.
|
|
68
|
+
let calls = 0
|
|
69
|
+
let resolveFetch!: (r: RequirementReview) => void
|
|
70
|
+
const pending = new Promise<RequirementReview>((res) => {
|
|
71
|
+
resolveFetch = res
|
|
72
|
+
})
|
|
73
|
+
vi.stubGlobal('useApi', () => ({
|
|
74
|
+
getRequirementReview: () => {
|
|
75
|
+
calls++
|
|
76
|
+
return pending
|
|
77
|
+
},
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
const store = useRequirementsStore()
|
|
81
|
+
const first = store.load('b1')
|
|
82
|
+
const second = store.load('b1')
|
|
83
|
+
expect(calls).toBe(1)
|
|
84
|
+
|
|
85
|
+
resolveFetch(review())
|
|
86
|
+
await Promise.all([first, second])
|
|
87
|
+
expect(calls).toBe(1)
|
|
88
|
+
expect(store.reviewFor('b1')?.id).toBe('rr1')
|
|
89
|
+
|
|
90
|
+
// Once the in-flight request settles, a later load fetches fresh.
|
|
91
|
+
void store.load('b1')
|
|
92
|
+
expect(calls).toBe(2)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
RequirementReview,
|
|
5
|
+
ResolveRequirementsExceededChoice,
|
|
6
|
+
ReviewItemStatus,
|
|
7
|
+
} from '~/types/requirements'
|
|
8
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Requirements-review state. On the pipeline path the reviewer runs as the first gate
|
|
12
|
+
* step: the run parks while the human answers/dismisses findings, then asks to incorporate.
|
|
13
|
+
* Incorporation + the re-review run ASYNCHRONOUSLY in the durable driver — the call returns
|
|
14
|
+
* at once (status `incorporating`) and the user goes back to the board; they are summoned
|
|
15
|
+
* again (a notification) only if the re-review yields findings or hits the cap. The store is
|
|
16
|
+
* patched both from call responses and from live `requirements` stream events (see
|
|
17
|
+
* `upsert`). `available` mirrors the backend's opt-in gate (a 503 hides the UI).
|
|
18
|
+
* Per-workspace; nothing is persisted client-side.
|
|
19
|
+
*/
|
|
20
|
+
export const useRequirementsStore = defineStore('requirements', () => {
|
|
21
|
+
const api = useApi()
|
|
22
|
+
const workspace = useWorkspaceStore()
|
|
23
|
+
|
|
24
|
+
/** null = unknown (not probed), true/false = feature on/off. */
|
|
25
|
+
const available = ref<boolean | null>(null)
|
|
26
|
+
/** The current review per block id (null = fetched, none exists). */
|
|
27
|
+
const reviews = ref<Record<string, RequirementReview | null>>({})
|
|
28
|
+
/** Block ids whose reviewer is currently running (review / re-review). */
|
|
29
|
+
const reviewing = ref<Set<string>>(new Set())
|
|
30
|
+
/** Review ids currently incorporating their answers. */
|
|
31
|
+
const incorporating = ref<Set<string>>(new Set())
|
|
32
|
+
/** Block ids whose current review is being fetched (the initial `load`). */
|
|
33
|
+
const loadingByBlock = ref<Set<string>>(new Set())
|
|
34
|
+
/**
|
|
35
|
+
* In-flight `load()` promises keyed by block id, so concurrent callers for the same
|
|
36
|
+
* block (the inspector's badge watch + the review window opening together) share ONE
|
|
37
|
+
* request instead of each firing its own. Plain Map — internal bookkeeping, not
|
|
38
|
+
* reactive. Cleared once the request settles.
|
|
39
|
+
*/
|
|
40
|
+
const inFlight = new Map<string, Promise<void>>()
|
|
41
|
+
|
|
42
|
+
function reviewFor(blockId: string): RequirementReview | null {
|
|
43
|
+
return reviews.value[blockId] ?? null
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The async background stage a block's review is in, or null. While the driver folds the
|
|
47
|
+
* answers (`incorporating`) then re-reviews the document (`reviewing`), NO human action is
|
|
48
|
+
* needed — so the board suppresses the "Approval needed" gate and shows this working state
|
|
49
|
+
* instead, with copy that names which of the two stages is running.
|
|
50
|
+
*/
|
|
51
|
+
function backgroundStage(blockId: string): 'incorporating' | 'reviewing' | null {
|
|
52
|
+
const status = reviews.value[blockId]?.status
|
|
53
|
+
return status === 'incorporating' || status === 'reviewing' ? status : null
|
|
54
|
+
}
|
|
55
|
+
function isReviewing(blockId: string): boolean {
|
|
56
|
+
return reviewing.value.has(blockId)
|
|
57
|
+
}
|
|
58
|
+
function isLoading(blockId: string): boolean {
|
|
59
|
+
return loadingByBlock.value.has(blockId)
|
|
60
|
+
}
|
|
61
|
+
function isIncorporating(reviewId: string): boolean {
|
|
62
|
+
return incorporating.value.has(reviewId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Findings still needing a human (status `open`). */
|
|
66
|
+
function openCount(review: RequirementReview): number {
|
|
67
|
+
return review.items.filter((i) => i.status === 'open').length
|
|
68
|
+
}
|
|
69
|
+
/** Findings the human answered (a reply recorded), which the companion folds in. */
|
|
70
|
+
function answeredCount(review: RequirementReview): number {
|
|
71
|
+
return review.items.filter((i) => i.status === 'answered' || i.status === 'resolved').length
|
|
72
|
+
}
|
|
73
|
+
/** Every finding is settled (answered or dismissed) — none still open. */
|
|
74
|
+
function allSettled(review: RequirementReview): boolean {
|
|
75
|
+
return openCount(review) === 0
|
|
76
|
+
}
|
|
77
|
+
/** Incorporation is possible: all findings settled AND at least one was answered. */
|
|
78
|
+
function canIncorporate(review: RequirementReview): boolean {
|
|
79
|
+
return allSettled(review) && answeredCount(review) > 0
|
|
80
|
+
}
|
|
81
|
+
/** Proceed (skip the companion) is possible: all findings settled but none answered. */
|
|
82
|
+
function canProceed(review: RequirementReview): boolean {
|
|
83
|
+
return allSettled(review) && answeredCount(review) === 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function store(review: RequirementReview) {
|
|
87
|
+
reviews.value = { ...reviews.value, [review.blockId]: review }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function withFlag(set: typeof reviewing, key: string, on: boolean) {
|
|
91
|
+
const next = new Set(set.value)
|
|
92
|
+
if (on) next.add(key)
|
|
93
|
+
else next.delete(key)
|
|
94
|
+
set.value = next
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Fetch the current review for a block (probing the feature's availability). */
|
|
98
|
+
async function load(blockId: string) {
|
|
99
|
+
if (!workspace.workspaceId) return
|
|
100
|
+
// Coalesce overlapping loads of the same block onto a single request.
|
|
101
|
+
const pending = inFlight.get(blockId)
|
|
102
|
+
if (pending) return pending
|
|
103
|
+
const promise = (async () => {
|
|
104
|
+
withFlag(loadingByBlock, blockId, true)
|
|
105
|
+
try {
|
|
106
|
+
const review = await api.getRequirementReview(workspace.requireId(), blockId)
|
|
107
|
+
available.value = true
|
|
108
|
+
reviews.value = { ...reviews.value, [blockId]: review }
|
|
109
|
+
} catch {
|
|
110
|
+
// 503 (feature off) or any error → hide the UI entry points.
|
|
111
|
+
available.value = false
|
|
112
|
+
} finally {
|
|
113
|
+
withFlag(loadingByBlock, blockId, false)
|
|
114
|
+
inFlight.delete(blockId)
|
|
115
|
+
}
|
|
116
|
+
})()
|
|
117
|
+
inFlight.set(blockId, promise)
|
|
118
|
+
return promise
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Record a human's answer to one item. */
|
|
122
|
+
async function reply(review: RequirementReview, itemId: string, text: string) {
|
|
123
|
+
store(await api.replyRequirementItem(workspace.requireId(), review.id, itemId, text))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Set an item's status (dismiss / reopen). */
|
|
127
|
+
async function setItemStatus(
|
|
128
|
+
review: RequirementReview,
|
|
129
|
+
itemId: string,
|
|
130
|
+
status: ReviewItemStatus,
|
|
131
|
+
) {
|
|
132
|
+
store(await api.setRequirementItemStatus(workspace.requireId(), review.id, itemId, status))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Ask the driver to incorporate the answers ASYNCHRONOUSLY. Optional `feedback` is the "do
|
|
137
|
+
* it differently" direction when redoing a merge. Returns at once with the `incorporating`
|
|
138
|
+
* review (the fold + re-review run in the background); the caller returns the user to the
|
|
139
|
+
* board. A live `requirements` event / a notification reflects the outcome later.
|
|
140
|
+
*/
|
|
141
|
+
async function incorporate(review: RequirementReview, feedback?: string) {
|
|
142
|
+
withFlag(incorporating, review.id, true)
|
|
143
|
+
try {
|
|
144
|
+
const updated = await api.incorporateRequirements(
|
|
145
|
+
workspace.requireId(),
|
|
146
|
+
review.blockId,
|
|
147
|
+
feedback,
|
|
148
|
+
)
|
|
149
|
+
store(updated)
|
|
150
|
+
return updated
|
|
151
|
+
} finally {
|
|
152
|
+
withFlag(incorporating, review.id, false)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Re-review the incorporated document (one more reviewer pass; may converge/advance). */
|
|
157
|
+
async function reReview(blockId: string): Promise<RequirementReview> {
|
|
158
|
+
withFlag(reviewing, blockId, true)
|
|
159
|
+
try {
|
|
160
|
+
const updated = await api.reReviewRequirements(workspace.requireId(), blockId)
|
|
161
|
+
store(updated)
|
|
162
|
+
return updated
|
|
163
|
+
} finally {
|
|
164
|
+
withFlag(reviewing, blockId, false)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Proceed: settle the requirements and advance the parked run. */
|
|
169
|
+
async function proceed(blockId: string): Promise<RequirementReview> {
|
|
170
|
+
const updated = await api.proceedRequirements(workspace.requireId(), blockId)
|
|
171
|
+
store(updated)
|
|
172
|
+
return updated
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolve a capped review: extra-round / proceed / stop-reset. */
|
|
176
|
+
async function resolveExceeded(
|
|
177
|
+
blockId: string,
|
|
178
|
+
choice: ResolveRequirementsExceededChoice,
|
|
179
|
+
): Promise<RequirementReview> {
|
|
180
|
+
const updated = await api.resolveRequirementsExceeded(workspace.requireId(), blockId, choice)
|
|
181
|
+
store(updated)
|
|
182
|
+
return updated
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
available,
|
|
187
|
+
reviews,
|
|
188
|
+
reviewFor,
|
|
189
|
+
backgroundStage,
|
|
190
|
+
isReviewing,
|
|
191
|
+
isLoading,
|
|
192
|
+
isIncorporating,
|
|
193
|
+
openCount,
|
|
194
|
+
answeredCount,
|
|
195
|
+
allSettled,
|
|
196
|
+
canIncorporate,
|
|
197
|
+
canProceed,
|
|
198
|
+
load,
|
|
199
|
+
reply,
|
|
200
|
+
setItemStatus,
|
|
201
|
+
incorporate,
|
|
202
|
+
reReview,
|
|
203
|
+
proceed,
|
|
204
|
+
resolveExceeded,
|
|
205
|
+
// Patch the cache from a live `requirements` stream event.
|
|
206
|
+
upsert: store,
|
|
207
|
+
}
|
|
208
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The workspace's default service-fragment selection — the best-practice fragment ids
|
|
7
|
+
* a NEW service inherits onto its `serviceFragmentIds`. Hydrated from the workspace
|
|
8
|
+
* snapshot; edited via the Default-service-fragments settings panel, which replaces
|
|
9
|
+
* the whole list on save. Changing it does not retroactively change existing services.
|
|
10
|
+
*/
|
|
11
|
+
export const useServiceFragmentDefaultsStore = defineStore('serviceFragmentDefaults', () => {
|
|
12
|
+
const api = useApi()
|
|
13
|
+
|
|
14
|
+
/** The default fragment ids new services inherit. */
|
|
15
|
+
const fragmentIds = ref<string[]>([])
|
|
16
|
+
|
|
17
|
+
function hydrate(ids: string[] | undefined) {
|
|
18
|
+
fragmentIds.value = [...(ids ?? [])]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Replace the whole default list and persist it. */
|
|
22
|
+
async function set(ids: string[]) {
|
|
23
|
+
const ws = useWorkspaceStore()
|
|
24
|
+
const saved = await api.setServiceFragmentDefaults(ws.requireId(), ids)
|
|
25
|
+
fragmentIds.value = [...saved.fragmentIds]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { fragmentIds, hydrate, set }
|
|
29
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { Service, WorkspaceMount } from '~/types/services'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-org shared services. A `Service` is account-owned (a service frame + its subtree + repo)
|
|
8
|
+
* and can be mounted onto several teams' boards; a `WorkspaceMount` places one onto THIS
|
|
9
|
+
* board with its own frame layout. Hydrated from the workspace snapshot:
|
|
10
|
+
* - `mounts` — the services this board mounts (drives the per-board frame layout),
|
|
11
|
+
* - `catalog` — the org's services this board can mount from (each with a `mountCount`).
|
|
12
|
+
*/
|
|
13
|
+
export const useServicesStore = defineStore('services', () => {
|
|
14
|
+
const api = useApi()
|
|
15
|
+
|
|
16
|
+
const mounts = ref<WorkspaceMount[]>([])
|
|
17
|
+
const catalog = ref<Service[]>([])
|
|
18
|
+
|
|
19
|
+
function hydrate(nextMounts: WorkspaceMount[], nextCatalog: Service[]) {
|
|
20
|
+
mounts.value = [...nextMounts]
|
|
21
|
+
catalog.value = [...nextCatalog]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Mount row keyed by service id. */
|
|
25
|
+
const byServiceId = computed<Record<string, WorkspaceMount>>(() => {
|
|
26
|
+
const map: Record<string, WorkspaceMount> = {}
|
|
27
|
+
for (const m of mounts.value) map[m.serviceId] = m
|
|
28
|
+
return map
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** Catalog service keyed by its frame block id (resolve a frame → its service). */
|
|
32
|
+
const serviceByFrameBlock = computed<Record<string, Service>>(() => {
|
|
33
|
+
const map: Record<string, Service> = {}
|
|
34
|
+
for (const s of catalog.value) map[s.frameBlockId] = s
|
|
35
|
+
return map
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Org services NOT yet mounted on this board (the "add existing service" picker's options). */
|
|
39
|
+
const mountable = computed<Service[]>(() => {
|
|
40
|
+
const mounted = new Set(mounts.value.map((m) => m.serviceId))
|
|
41
|
+
return catalog.value.filter((s) => !mounted.has(s.id))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** A frame is "shared" when its service is mounted on more than one board. */
|
|
45
|
+
function isSharedFrame(frameBlockId: string): boolean {
|
|
46
|
+
return (serviceByFrameBlock.value[frameBlockId]?.mountCount ?? 0) > 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function mount(serviceId: string, position?: { x: number; y: number }) {
|
|
50
|
+
const ws = useWorkspaceStore()
|
|
51
|
+
const created = await api.mountService(ws.requireId(), serviceId, position ? { position } : {})
|
|
52
|
+
await ws.refresh()
|
|
53
|
+
return created
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function unmount(serviceId: string) {
|
|
57
|
+
const ws = useWorkspaceStore()
|
|
58
|
+
await api.unmountService(ws.requireId(), serviceId)
|
|
59
|
+
await ws.refresh()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Persist a mounted frame's per-board layout (called on frame drag/resize end). */
|
|
63
|
+
async function updateLayout(
|
|
64
|
+
serviceId: string,
|
|
65
|
+
position?: { x: number; y: number },
|
|
66
|
+
size?: { w: number; h: number } | null,
|
|
67
|
+
) {
|
|
68
|
+
const ws = useWorkspaceStore()
|
|
69
|
+
const updated = await api.updateMountLayout(ws.requireId(), serviceId, { position, size })
|
|
70
|
+
const local = mounts.value.find((m) => m.serviceId === serviceId)
|
|
71
|
+
if (local) Object.assign(local, updated)
|
|
72
|
+
return updated
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
mounts,
|
|
77
|
+
catalog,
|
|
78
|
+
byServiceId,
|
|
79
|
+
serviceByFrameBlock,
|
|
80
|
+
mountable,
|
|
81
|
+
isSharedFrame,
|
|
82
|
+
hydrate,
|
|
83
|
+
mount,
|
|
84
|
+
unmount,
|
|
85
|
+
updateLayout,
|
|
86
|
+
}
|
|
87
|
+
})
|