@cat-factory/app 0.190.0 → 0.190.1
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 +10 -0
- package/app/components/environments/EnvironmentSetupWizard.vue +1 -0
- package/app/components/panels/AgentStepDetail.vue +2 -0
- package/app/components/panels/ReportsPanel.vue +1 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +1 -0
- package/app/components/pipeline/PipelinePreview.vue +1 -0
- package/app/components/prReview/PrReviewWindow.vue +1 -0
- package/app/components/settings/RiskPolicyPanel.vue +1 -0
- package/app/composables/api/client.spec.ts +50 -0
- package/app/composables/api/client.ts +26 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,16 @@ over the WebSocket. How that sync works is written up in
|
|
|
54
54
|
| `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
|
|
55
55
|
| `utils/` | Small pure helpers. |
|
|
56
56
|
|
|
57
|
+
### Always import a layer component explicitly
|
|
58
|
+
|
|
59
|
+
**Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.
|
|
60
|
+
|
|
61
|
+
Some bare tags do work, which is exactly what makes this worth writing down. Nuxt drops a directory segment the filename already repeats, so `pipeline/PipelinePicker.vue` registers as `PipelinePicker` and resolves bare, while `pipeline/AgentKindIcon.vue` in the same folder registers as `PipelineAgentKindIcon` and does not. Whether a tag resolves therefore depends on a coincidence between a folder name and a filename, and renaming either end breaks the tag with no error. An explicit import does not care.
|
|
62
|
+
|
|
63
|
+
The failure is silent, which is why this is a rule rather than a preference. An unresolved tag warns in dev and then renders nothing, so a built SPA has a hole where the component should be. Nothing catches it: not typecheck, not the unit tests, not the e2e suite, and not the user, who reads it as a backend returning no data. Seven components had shipped this way.
|
|
64
|
+
|
|
65
|
+
`scripts/check-component-imports.mjs` enforces it (CI's `repo-guards` job). If a panel section is missing and the data looks right, check the import first.
|
|
66
|
+
|
|
57
67
|
## Interface modes (basic / advanced)
|
|
58
68
|
|
|
59
69
|
The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { computed } from 'vue'
|
|
18
18
|
import { JourneyHost, JourneyOutlet } from '@modular-vue/journeys'
|
|
19
19
|
import { environmentSetupHandle } from '~/modular/journeys/environmentSetup'
|
|
20
|
+
import EnvSetupStepper from '~/components/environments/EnvSetupStepper.vue'
|
|
20
21
|
|
|
21
22
|
const ui = useUiStore()
|
|
22
23
|
const { t } = useI18n()
|
|
@@ -6,6 +6,8 @@ import { agentKindMeta } from '~/utils/catalog'
|
|
|
6
6
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
7
7
|
import StepMetadataCard from '~/components/panels/StepMetadataCard.vue'
|
|
8
8
|
import StepTestReport from '~/components/panels/StepTestReport.vue'
|
|
9
|
+
import StepEffortReport from '~/components/panels/StepEffortReport.vue'
|
|
10
|
+
import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
|
|
9
11
|
import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
|
|
10
12
|
import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
|
|
11
13
|
import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
segmentPct,
|
|
17
17
|
trendMagnitude,
|
|
18
18
|
} from './ReportsPanel.logic'
|
|
19
|
+
import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue'
|
|
19
20
|
|
|
20
21
|
// Reports: cross-cutting usage analytics for the active account — where the spend and the
|
|
21
22
|
// work actually go. Spend per model and agent kind, spend + run activity per workspace /
|
|
@@ -8,6 +8,7 @@ import { showOverrideField } from '~/utils/uiMode'
|
|
|
8
8
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
9
9
|
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
10
10
|
import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
|
|
11
|
+
import DocReferenceRepos from '~/components/panels/inspector/DocReferenceRepos.vue'
|
|
11
12
|
|
|
12
13
|
const props = defineProps<{ block: Block }>()
|
|
13
14
|
|
|
@@ -12,6 +12,7 @@ import { computed } from 'vue'
|
|
|
12
12
|
import type { Pipeline } from '~/types/domain'
|
|
13
13
|
import { agentKindMeta } from '~/utils/catalog'
|
|
14
14
|
import { pipelineDisplaySteps, pipelineGateCount } from '~/utils/pipeline'
|
|
15
|
+
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
15
16
|
|
|
16
17
|
const props = defineProps<{ pipeline: Pipeline }>()
|
|
17
18
|
const { t } = useI18n()
|
|
@@ -24,6 +24,7 @@ import { subtaskIconClass } from '~/utils/pipelineRender'
|
|
|
24
24
|
import { activeChunkLabels, chunkReviewPercent, hasNoSlicePlan } from '~/utils/prReviewProgress'
|
|
25
25
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
26
26
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
27
|
+
import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
|
|
27
28
|
|
|
28
29
|
const execution = useExecutionStore()
|
|
29
30
|
const board = useBoardStore()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ApiContract } from '@toad-contracts/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { type SendParams, withoutUndefinedQueryParams } from '~/composables/api/client'
|
|
4
|
+
|
|
5
|
+
// An omitted optional query param used to reach the server as `key=`: the contract client
|
|
6
|
+
// serialises with `fast-querystring`, whose `stringify({ blockId: undefined })` is `'blockId='`,
|
|
7
|
+
// and request validation waves the key through because `v.optional(...)` accepts `undefined`. On
|
|
8
|
+
// `listTasksContract`, whose `blockId` carries a `minLength(1)`, that made every unscoped
|
|
9
|
+
// `listTasks()` a guaranteed 400. These lock the strip in.
|
|
10
|
+
|
|
11
|
+
// The helper is contract-generic and only ever reads `queryParams`, so the cases below describe
|
|
12
|
+
// request params structurally rather than picking a real contract per shape.
|
|
13
|
+
type Params = Record<string, unknown>
|
|
14
|
+
const strip = (params: Params): Params =>
|
|
15
|
+
withoutUndefinedQueryParams(params as SendParams<ApiContract>) as Params
|
|
16
|
+
|
|
17
|
+
describe('withoutUndefinedQueryParams', () => {
|
|
18
|
+
it('drops undefined-valued query keys so they never reach the query string', () => {
|
|
19
|
+
const out = strip({ pathPrefix: '/workspaces/ws_1', queryParams: { blockId: undefined } })
|
|
20
|
+
expect(out.queryParams).toEqual({})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// The guard against "fix" it with a falsy check: 0, false and '' are all values a caller
|
|
24
|
+
// deliberately sent, and only `undefined` means absent.
|
|
25
|
+
it('keeps defined values, including falsy ones a caller meant to send', () => {
|
|
26
|
+
const out = strip({ queryParams: { blockId: 'blk_1', page: 0, all: false, q: '' } })
|
|
27
|
+
expect(out.queryParams).toEqual({ blockId: 'blk_1', page: 0, all: false, q: '' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('drops only the undefined keys from a mixed set', () => {
|
|
31
|
+
const out = strip({ queryParams: { window: '7d', workspaceId: undefined } })
|
|
32
|
+
expect(out.queryParams).toEqual({ window: '7d' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('leaves other request params untouched', () => {
|
|
36
|
+
const params = {
|
|
37
|
+
pathPrefix: '/workspaces/ws_1',
|
|
38
|
+
pathParams: { source: 'jira' },
|
|
39
|
+
body: { a: 1 },
|
|
40
|
+
}
|
|
41
|
+
expect(strip(params)).toEqual(params)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns the same object when there is nothing to strip', () => {
|
|
45
|
+
const params = { queryParams: { blockId: 'blk_1' } }
|
|
46
|
+
expect(strip(params)).toBe(params)
|
|
47
|
+
const noQuery = { pathPrefix: '/workspaces/ws_1' }
|
|
48
|
+
expect(strip(noQuery)).toBe(noQuery)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -71,6 +71,31 @@ export function createApiClient(): WretchInstance {
|
|
|
71
71
|
])
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Drop query keys whose value is `undefined`, so an omitted optional param is absent from the
|
|
76
|
+
* query string instead of present-but-empty.
|
|
77
|
+
*
|
|
78
|
+
* `sendByApiContract` serialises with `fast-querystring`, and `stringify({ blockId: undefined })`
|
|
79
|
+
* is `'blockId='`. Request validation does not catch it, because `v.optional(...)` accepts
|
|
80
|
+
* `undefined` and the key only becomes empty on the way out. The server then validates the parsed
|
|
81
|
+
* `''`, so any optional param carrying a `minLength(1)` rejects the whole request with a 400. That
|
|
82
|
+
* is what made an unscoped `listTasks()` uncallable. Params with no length check are luckier but
|
|
83
|
+
* still wrong: the handler reads `''` where it asked for absence.
|
|
84
|
+
*
|
|
85
|
+
* Stripping at this one chokepoint fixes every contract at once, which is the point. Writing
|
|
86
|
+
* `queryParams: { foo }` for an optional `foo` is the obvious thing to write and it should work,
|
|
87
|
+
* rather than each call site remembering to spread the key in conditionally.
|
|
88
|
+
*/
|
|
89
|
+
export function withoutUndefinedQueryParams<T extends ApiContract>(
|
|
90
|
+
params: SendParams<T>,
|
|
91
|
+
): SendParams<T> {
|
|
92
|
+
const query = (params as { queryParams?: Record<string, unknown> }).queryParams
|
|
93
|
+
if (!query) return params
|
|
94
|
+
const present = Object.entries(query).filter(([, value]) => value !== undefined)
|
|
95
|
+
if (present.length === Object.keys(query).length) return params
|
|
96
|
+
return { ...params, queryParams: Object.fromEntries(present) }
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
/**
|
|
75
100
|
* Send a contract request and unwrap to the success body (or throw the typed error).
|
|
76
101
|
* The public signature preserves per-contract inference for callers; inside,
|
|
@@ -82,7 +107,7 @@ export async function sendContract<T extends ApiContract>(
|
|
|
82
107
|
contract: T,
|
|
83
108
|
params: SendParams<T>,
|
|
84
109
|
): Promise<SuccessBodyOf<T>> {
|
|
85
|
-
const outcome = await sendByApiContract(client, contract, params)
|
|
110
|
+
const outcome = await sendByApiContract(client, contract, withoutUndefinedQueryParams(params))
|
|
86
111
|
if (outcome.error) {
|
|
87
112
|
const error = outcome.error
|
|
88
113
|
// A contract-declared non-2xx is reported as a plain `{ statusCode, headers, body }`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.190.
|
|
3
|
+
"version": "0.190.1",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|