@cat-factory/app 0.139.0 → 0.141.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/app/components/board/AddTaskModal.vue +41 -48
- package/app/components/documents/ContextDocumentPicker.vue +89 -70
- package/app/components/documents/RepoContextDocPicker.vue +277 -0
- package/app/components/focus/BlockFocusView.vue +4 -3
- package/app/components/github/RepoTreeBrowser.vue +19 -10
- package/app/components/palettes/AgentPalette.vue +13 -5
- package/app/components/panels/inspector/TaskRunSettings.vue +21 -27
- package/app/components/pipeline/PipelineBuilder.vue +65 -3
- package/app/components/pipeline/PipelinePicker.vue +120 -0
- package/app/components/pipeline/PipelinePreview.vue +49 -0
- package/app/composables/api/github.ts +8 -0
- package/app/composables/useContextLinking.spec.ts +138 -0
- package/app/composables/useContextLinking.ts +120 -8
- package/app/composables/useCopyToClipboard.spec.ts +27 -0
- package/app/composables/useCopyToClipboard.ts +17 -1
- package/app/stores/github.ts +24 -0
- package/app/stores/pipelines.ts +24 -1
- package/app/types/domain.ts +1 -0
- package/app/utils/pipeline.spec.ts +66 -0
- package/app/utils/pipeline.ts +43 -4
- package/i18n/locales/de.json +31 -1
- package/i18n/locales/en.json +31 -1
- package/i18n/locales/es.json +31 -1
- package/i18n/locales/fr.json +31 -1
- package/i18n/locales/he.json +31 -1
- package/i18n/locales/it.json +31 -1
- package/i18n/locales/ja.json +31 -1
- package/i18n/locales/pl.json +31 -1
- package/i18n/locales/tr.json +31 -1
- package/i18n/locales/uk.json +31 -1
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DocumentSourceKind, TaskSourceKind } from '~/types/domain'
|
|
2
|
+
import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
|
|
2
3
|
|
|
3
4
|
// Shared model + orchestration for attaching external context (imported docs and
|
|
4
5
|
// tracker issues) to a board block. A "pending" item is something the user has
|
|
@@ -30,22 +31,96 @@ export interface PendingContext {
|
|
|
30
31
|
needsImport: boolean
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A single pending attachment that failed to import or link, captured with its
|
|
36
|
+
* actual cause instead of swallowed. The message is the server's own explanation
|
|
37
|
+
* (e.g. "GitHub denied access to …" / "… was not found on the default branch"),
|
|
38
|
+
* the status is the HTTP code, and the code is the backend error code
|
|
39
|
+
* (`conflict` / `validation` / …) — enough to both display a specific reason and
|
|
40
|
+
* assemble a copy-pasteable diagnostic report.
|
|
41
|
+
*/
|
|
42
|
+
export interface LinkFailure {
|
|
43
|
+
item: PendingContext
|
|
44
|
+
/** The server's message (or a network-fault message) explaining why it failed. */
|
|
45
|
+
message: string
|
|
46
|
+
/** HTTP status of the failed request, when the error carried one. */
|
|
47
|
+
status?: number
|
|
48
|
+
/** Backend error code (`conflict` / `validation` / …), when present. */
|
|
49
|
+
code?: string
|
|
50
|
+
/**
|
|
51
|
+
* The backend error envelope's `details` bag, when present — for a GitHub doc read
|
|
52
|
+
* this carries the repo coordinates + the UPSTREAM GitHub status (e.g. `status: 403`),
|
|
53
|
+
* which differs from the HTTP `status` above (the mapped response code, e.g. 409). Kept
|
|
54
|
+
* so the diagnostic report shows the real GitHub status, not just the mapped one.
|
|
55
|
+
*/
|
|
56
|
+
details?: Record<string, unknown>
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
/** Stable key for a pending item, used for dedupe + selection toggles. */
|
|
34
60
|
export function contextKey(c: Pick<PendingContext, 'kind' | 'source' | 'externalId'>): string {
|
|
35
61
|
return `${c.kind}:${c.source}:${c.externalId}`
|
|
36
62
|
}
|
|
37
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Render a batch of {@link LinkFailure}s into a single plain-text diagnostic block
|
|
66
|
+
* for the clipboard — the exact context a bug report needs (the item coordinates,
|
|
67
|
+
* the HTTP status + backend code, the backend `details` bag, and the server's message)
|
|
68
|
+
* so the user does not have to retype any of it. Deliberately English/technical (a log
|
|
69
|
+
* dump, not UI prose), mirroring how format/code examples stay out of the i18n catalog.
|
|
70
|
+
*/
|
|
71
|
+
export function buildLinkFailureReport(
|
|
72
|
+
failures: LinkFailure[],
|
|
73
|
+
context: { workspaceId?: string | null; blockId?: string; when?: string } = {},
|
|
74
|
+
): string {
|
|
75
|
+
const lines: string[] = []
|
|
76
|
+
lines.push(`Context link failures: ${failures.length}`)
|
|
77
|
+
if (context.when) lines.push(`when: ${context.when}`)
|
|
78
|
+
if (context.workspaceId) lines.push(`workspace: ${context.workspaceId}`)
|
|
79
|
+
if (context.blockId) lines.push(`block: ${context.blockId}`)
|
|
80
|
+
for (const f of failures) {
|
|
81
|
+
lines.push('')
|
|
82
|
+
lines.push(`- ${f.item.kind}/${f.item.source}: ${f.item.externalId}`)
|
|
83
|
+
lines.push(` title: ${f.item.title}`)
|
|
84
|
+
if (f.status !== undefined) lines.push(` status: ${f.status}`)
|
|
85
|
+
if (f.code) lines.push(` code: ${f.code}`)
|
|
86
|
+
// Dump the backend `details` (repo coordinates + upstream status), each key namespaced
|
|
87
|
+
// so its `status` reads clearly as the GitHub status, distinct from the HTTP `status`.
|
|
88
|
+
for (const [key, value] of Object.entries(f.details ?? {})) {
|
|
89
|
+
lines.push(` details.${key}: ${formatDetailValue(value)}`)
|
|
90
|
+
}
|
|
91
|
+
lines.push(` error: ${f.message}`)
|
|
92
|
+
}
|
|
93
|
+
return lines.join('\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One-line rendering of a `details` value for the diagnostic dump (objects → JSON). */
|
|
97
|
+
function formatDetailValue(value: unknown): string {
|
|
98
|
+
if (value === null || value === undefined) return String(value)
|
|
99
|
+
if (typeof value === 'object') {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.stringify(value)
|
|
102
|
+
} catch {
|
|
103
|
+
return String(value)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return String(value)
|
|
107
|
+
}
|
|
108
|
+
|
|
38
109
|
export function useContextLinking() {
|
|
39
110
|
const documents = useDocumentsStore()
|
|
40
111
|
const tasks = useTasksStore()
|
|
112
|
+
const workspace = useWorkspaceStore()
|
|
113
|
+
const toast = useToast()
|
|
114
|
+
const { t } = useI18n()
|
|
115
|
+
const { copyAction } = useCopyToClipboard()
|
|
41
116
|
|
|
42
117
|
/**
|
|
43
118
|
* Import (when needed) then link every pending item to `blockId`. Each failure
|
|
44
|
-
* is
|
|
45
|
-
* the rest; returns
|
|
119
|
+
* is captured with its actual cause rather than aborting the batch, so one bad
|
|
120
|
+
* attachment doesn't sink the rest; returns the failures (empty ⇒ all linked).
|
|
46
121
|
*/
|
|
47
|
-
async function linkPending(blockId: string, items: PendingContext[]): Promise<
|
|
48
|
-
|
|
122
|
+
async function linkPending(blockId: string, items: PendingContext[]): Promise<LinkFailure[]> {
|
|
123
|
+
const failures: LinkFailure[] = []
|
|
49
124
|
for (const item of items) {
|
|
50
125
|
try {
|
|
51
126
|
if (item.kind === 'document') {
|
|
@@ -61,12 +136,49 @@ export function useContextLinking() {
|
|
|
61
136
|
: item.externalId
|
|
62
137
|
await tasks.linkToBlock(blockId, source, externalId)
|
|
63
138
|
}
|
|
64
|
-
} catch {
|
|
65
|
-
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// Never swallow the cause: capture the server's own message + status/code/details
|
|
141
|
+
// so the toast can name the specific reason and the copy affordance can carry the
|
|
142
|
+
// full context (incl. the upstream GitHub status the backend puts on `details`).
|
|
143
|
+
const envelope = apiErrorEnvelope(e)
|
|
144
|
+
failures.push({
|
|
145
|
+
item,
|
|
146
|
+
message: e instanceof Error ? e.message : String(e),
|
|
147
|
+
status: apiErrorStatus(e),
|
|
148
|
+
code: envelope?.code,
|
|
149
|
+
details:
|
|
150
|
+
envelope?.details && typeof envelope.details === 'object'
|
|
151
|
+
? (envelope.details as Record<string, unknown>)
|
|
152
|
+
: undefined,
|
|
153
|
+
})
|
|
66
154
|
}
|
|
67
155
|
}
|
|
68
|
-
return
|
|
156
|
+
return failures
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Surface link failures as a single actionable toast: the specific per-item
|
|
161
|
+
* reasons as the body, and a "Copy details" action that puts the full diagnostic
|
|
162
|
+
* report ({@link buildLinkFailureReport}) on the clipboard. Sticky (`duration: 0`)
|
|
163
|
+
* so the cause stays readable long enough to act on. No-op when nothing failed.
|
|
164
|
+
*/
|
|
165
|
+
function presentLinkFailures(failures: LinkFailure[], blockId?: string): void {
|
|
166
|
+
if (failures.length === 0) return
|
|
167
|
+
const description = failures.map((f) => `${f.item.title}: ${f.message}`).join('\n')
|
|
168
|
+
const report = buildLinkFailureReport(failures, {
|
|
169
|
+
workspaceId: workspace.workspaceId,
|
|
170
|
+
blockId,
|
|
171
|
+
when: new Date().toISOString(),
|
|
172
|
+
})
|
|
173
|
+
toast.add({
|
|
174
|
+
title: t('board.addTask.linkFailed', { count: failures.length }, failures.length),
|
|
175
|
+
description,
|
|
176
|
+
icon: 'i-lucide-triangle-alert',
|
|
177
|
+
color: 'warning',
|
|
178
|
+
duration: 0,
|
|
179
|
+
actions: [copyAction(report)],
|
|
180
|
+
})
|
|
69
181
|
}
|
|
70
182
|
|
|
71
|
-
return { linkPending }
|
|
183
|
+
return { linkPending, presentLinkFailures }
|
|
72
184
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// `useCopyToClipboard` wraps VueUse's clipboard; mock it so `copyAction` can be exercised
|
|
4
|
+
// without a real clipboard. The global `useToast`/`useI18n` stubs come from test/setup.ts
|
|
5
|
+
// (`t` echoes the key), so the default label resolves to its i18n key.
|
|
6
|
+
const { writeClipboard } = vi.hoisted(() => ({ writeClipboard: vi.fn(async () => {}) }))
|
|
7
|
+
vi.mock('@vueuse/core', () => ({
|
|
8
|
+
useClipboard: () => ({ copy: writeClipboard, isSupported: { value: true } }),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
import { useCopyToClipboard } from '~/composables/useCopyToClipboard'
|
|
12
|
+
|
|
13
|
+
describe('copyAction', () => {
|
|
14
|
+
it('builds a Copy-details toast action that copies the given text', async () => {
|
|
15
|
+
const action = useCopyToClipboard().copyAction('diagnostic report')
|
|
16
|
+
expect(action.label).toBe('common.copyDetails')
|
|
17
|
+
expect(action.icon).toBe('i-lucide-clipboard')
|
|
18
|
+
|
|
19
|
+
action.onClick()
|
|
20
|
+
await Promise.resolve()
|
|
21
|
+
expect(writeClipboard).toHaveBeenCalledWith('diagnostic report')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('honours a custom label', () => {
|
|
25
|
+
expect(useCopyToClipboard().copyAction('x', 'Custom label').label).toBe('Custom label')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -25,5 +25,21 @@ export function useCopyToClipboard() {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A ready-made toast action that copies `text` (through {@link copy}, so it shows the
|
|
30
|
+
* same "Copied" / "Copy failed" feedback). Drop it into a toast's `actions` so any
|
|
31
|
+
* error/warning toast can offer a one-click "Copy details" — the message + context the
|
|
32
|
+
* user would otherwise have to retype into a bug report.
|
|
33
|
+
*/
|
|
34
|
+
function copyAction(text: string, label?: string) {
|
|
35
|
+
return {
|
|
36
|
+
label: label ?? t('common.copyDetails'),
|
|
37
|
+
icon: 'i-lucide-clipboard',
|
|
38
|
+
onClick: () => {
|
|
39
|
+
void copy(text)
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { copy, copyAction, isSupported }
|
|
29
45
|
}
|
package/app/stores/github.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
GitHubRepo,
|
|
12
12
|
MergePullRequestInput,
|
|
13
13
|
OpenPullRequestInput,
|
|
14
|
+
RepoTreeEntry,
|
|
14
15
|
ResyncRequest,
|
|
15
16
|
} from '~/types/domain'
|
|
16
17
|
import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
|
|
@@ -215,6 +216,26 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
215
216
|
return api.listGitHubRepoTree(workspace.requireId(), repoGithubId, path)
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/** Full file listing per repo (recursive tree), cached by GitHub numeric id. */
|
|
220
|
+
const repoFiles = ref<Record<number, RepoTreeEntry[]>>({})
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Load (and cache) EVERY file in a repo — the whole tree in one recursive read — so a
|
|
224
|
+
* picker can search files by path entirely client-side (no per-keystroke server call).
|
|
225
|
+
* Cached by repo id so re-opening the picker for the same repo is instant; force a
|
|
226
|
+
* refetch with `{ reload: true }`. Mirrors the branches cache.
|
|
227
|
+
*/
|
|
228
|
+
async function loadRepoFiles(
|
|
229
|
+
repoGithubId: number,
|
|
230
|
+
opts: { reload?: boolean } = {},
|
|
231
|
+
): Promise<RepoTreeEntry[]> {
|
|
232
|
+
const cached = repoFiles.value[repoGithubId]
|
|
233
|
+
if (cached && !opts.reload) return cached
|
|
234
|
+
const files = await api.listGitHubRepoFiles(workspace.requireId(), repoGithubId)
|
|
235
|
+
repoFiles.value = { ...repoFiles.value, [repoGithubId]: files }
|
|
236
|
+
return files
|
|
237
|
+
}
|
|
238
|
+
|
|
218
239
|
/** The URL a workspace owner visits to install the App against this workspace. */
|
|
219
240
|
function getInstallUrl(): Promise<string> {
|
|
220
241
|
return api.getGitHubInstallUrl(workspace.requireId()).then((r) => r.url)
|
|
@@ -313,6 +334,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
313
334
|
pulls.value = []
|
|
314
335
|
issues.value = []
|
|
315
336
|
branches.value = {}
|
|
337
|
+
repoFiles.value = {}
|
|
316
338
|
}
|
|
317
339
|
|
|
318
340
|
return {
|
|
@@ -347,6 +369,8 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
347
369
|
searchAvailableRepos,
|
|
348
370
|
setLinkedRepos,
|
|
349
371
|
loadRepoTree,
|
|
372
|
+
repoFiles,
|
|
373
|
+
loadRepoFiles,
|
|
350
374
|
loadBranches,
|
|
351
375
|
getInstallUrl,
|
|
352
376
|
loadInstallations,
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
|
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
3
|
import type { AgentKind, Pipeline } from '~/types/domain'
|
|
4
4
|
import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
|
|
5
|
-
import type { StepOptions, TesterQualityConfig } from '@cat-factory/contracts'
|
|
5
|
+
import type { PipelinePurpose, StepOptions, TesterQualityConfig } from '@cat-factory/contracts'
|
|
6
6
|
import { companionForProducer, uid } from '~/utils/catalog'
|
|
7
7
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
8
8
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
@@ -81,7 +81,16 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
81
81
|
const draftStepOptions = ref<(StepOptions | null)[]>([])
|
|
82
82
|
/** Organizational labels for the pipeline being assembled/edited. */
|
|
83
83
|
const draftLabels = ref<string[]>([])
|
|
84
|
+
/**
|
|
85
|
+
* The use-case classifier of the pipeline being assembled/edited (`build` / `document` /
|
|
86
|
+
* `review` / `research` / `planning`), or null when unclassified. Drives which task pickers
|
|
87
|
+
* offer the saved pipeline and which agent kinds the builder palette shows (a non-`build`
|
|
88
|
+
* purpose hides the Implementation/Testing kinds).
|
|
89
|
+
*/
|
|
90
|
+
const draftPurpose = ref<PipelinePurpose | null>(null)
|
|
84
91
|
const draftName = ref('New pipeline')
|
|
92
|
+
/** Prose description for the pipeline being assembled/edited (shown in the pickers). */
|
|
93
|
+
const draftDescription = ref('')
|
|
85
94
|
/** The id of the pipeline being edited, or null when assembling a brand-new one. */
|
|
86
95
|
const editingId = ref<string | null>(null)
|
|
87
96
|
|
|
@@ -321,7 +330,9 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
321
330
|
draftTesterQuality.value = []
|
|
322
331
|
draftStepOptions.value = []
|
|
323
332
|
draftLabels.value = []
|
|
333
|
+
draftPurpose.value = null
|
|
324
334
|
draftName.value = 'New pipeline'
|
|
335
|
+
draftDescription.value = ''
|
|
325
336
|
editingId.value = null
|
|
326
337
|
}
|
|
327
338
|
|
|
@@ -339,7 +350,9 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
339
350
|
)
|
|
340
351
|
draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
|
|
341
352
|
draftLabels.value = [...(pipeline.labels ?? [])]
|
|
353
|
+
draftPurpose.value = pipeline.purpose ?? null
|
|
342
354
|
draftName.value = pipeline.name
|
|
355
|
+
draftDescription.value = pipeline.description ?? ''
|
|
343
356
|
editingId.value = pipeline.id
|
|
344
357
|
}
|
|
345
358
|
|
|
@@ -347,6 +360,10 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
347
360
|
function draftPayload() {
|
|
348
361
|
return {
|
|
349
362
|
name: draftName.value.trim() || 'Untitled pipeline',
|
|
363
|
+
// ALWAYS send description (like stepOptions) so an update can CLEAR it — an omitted field
|
|
364
|
+
// reads as "keep existing", so toggling the last of the text away must send the empty string.
|
|
365
|
+
// The backend trims + drops a blank one, so this is a no-op on create / an empty description.
|
|
366
|
+
description: draftDescription.value.trim(),
|
|
350
367
|
agentKinds: [...draft.value],
|
|
351
368
|
// Only send gates when at least one step is gated.
|
|
352
369
|
...(draftGates.value.some(Boolean) ? { gates: [...draftGates.value] } : {}),
|
|
@@ -381,6 +398,10 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
381
398
|
stepOptions: draftStepOptions.value.map((o) => o ?? null),
|
|
382
399
|
// Only send labels when there are any.
|
|
383
400
|
...(draftLabels.value.length ? { labels: [...draftLabels.value] } : {}),
|
|
401
|
+
// Only send purpose when the pipeline is classified (null ⇒ leave unclassified). Like the
|
|
402
|
+
// legacy per-step arrays, an omitted `purpose` on update reads as "keep existing"; clearing
|
|
403
|
+
// a classification back to unclassified is not a supported edit (every built-in ships one).
|
|
404
|
+
...(draftPurpose.value ? { purpose: draftPurpose.value } : {}),
|
|
384
405
|
}
|
|
385
406
|
}
|
|
386
407
|
|
|
@@ -451,7 +472,9 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
451
472
|
draftTesterQuality,
|
|
452
473
|
draftStepOptions,
|
|
453
474
|
draftLabels,
|
|
475
|
+
draftPurpose,
|
|
454
476
|
draftName,
|
|
477
|
+
draftDescription,
|
|
455
478
|
editingId,
|
|
456
479
|
units,
|
|
457
480
|
hydrate,
|
package/app/types/domain.ts
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { pipelineAllowedForTaskType, purposeAllowsAgentCategory } from '@cat-factory/contracts'
|
|
3
|
+
import type { Block, Pipeline } from '~/types/domain'
|
|
4
|
+
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
5
|
+
|
|
6
|
+
// A minimal pipeline: only the fields the launch/task-type filters read matter here.
|
|
7
|
+
function pipeline(over: Partial<Pipeline> = {}): Pipeline {
|
|
8
|
+
return { id: 'pl_x', name: 'X', agentKinds: ['coder'], ...over } as Pipeline
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('pipelineAllowedForTaskType', () => {
|
|
12
|
+
it('a document task offers ONLY document-purpose pipelines', () => {
|
|
13
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), 'document')).toBe(true)
|
|
14
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), 'document')).toBe(false)
|
|
15
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'research' }), 'document')).toBe(false)
|
|
16
|
+
// An unclassified pipeline is hidden from a document task (it requires the explicit classifier).
|
|
17
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'document')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('every non-document task type is unrestricted (any purpose, and undefined type)', () => {
|
|
21
|
+
for (const type of ['feature', 'bug', 'spike', 'review', 'ralph', undefined] as const) {
|
|
22
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), type)).toBe(true)
|
|
23
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), type)).toBe(true)
|
|
24
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), type)).toBe(true)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('purposeAllowsAgentCategory (builder palette gate)', () => {
|
|
30
|
+
it('a build (or unclassified) pipeline may use every category', () => {
|
|
31
|
+
for (const purpose of ['build', null, undefined] as const) {
|
|
32
|
+
for (const cat of ['review', 'design', 'build', 'test', 'docs', 'gates'] as const) {
|
|
33
|
+
expect(purposeAllowsAgentCategory(purpose, cat)).toBe(true)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('a non-build pipeline hides the Implementation (build) and Testing (test) categories', () => {
|
|
39
|
+
for (const purpose of ['document', 'review', 'research', 'planning'] as const) {
|
|
40
|
+
expect(purposeAllowsAgentCategory(purpose, 'build')).toBe(false)
|
|
41
|
+
expect(purposeAllowsAgentCategory(purpose, 'test')).toBe(false)
|
|
42
|
+
// Non-code categories stay visible.
|
|
43
|
+
expect(purposeAllowsAgentCategory(purpose, 'docs')).toBe(true)
|
|
44
|
+
expect(purposeAllowsAgentCategory(purpose, 'review')).toBe(true)
|
|
45
|
+
expect(purposeAllowsAgentCategory(purpose, 'gates')).toBe(true)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('pipelineAllowedForManualStart composes the task-type gate', () => {
|
|
51
|
+
const noFrame = undefined
|
|
52
|
+
const blocks: Block[] = []
|
|
53
|
+
|
|
54
|
+
it('drops a non-document pipeline for a document task, keeps it for others', () => {
|
|
55
|
+
const build = pipeline({ purpose: 'build' })
|
|
56
|
+
expect(pipelineAllowedForManualStart(build, noFrame, blocks, 'document')).toBe(false)
|
|
57
|
+
expect(pipelineAllowedForManualStart(build, noFrame, blocks, 'feature')).toBe(true)
|
|
58
|
+
// No task type supplied ⇒ no task-type restriction.
|
|
59
|
+
expect(pipelineAllowedForManualStart(build, noFrame, blocks)).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('still excludes recurring-only pipelines regardless of task type', () => {
|
|
63
|
+
const recurring = pipeline({ purpose: 'document', availability: 'recurring' })
|
|
64
|
+
expect(pipelineAllowedForManualStart(recurring, noFrame, blocks, 'document')).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
})
|
package/app/utils/pipeline.ts
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
frameAllowsVisualPipeline,
|
|
3
|
+
pipelineAllowedForTaskType,
|
|
4
|
+
pipelineHasVisualStep,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { AgentKind, Block, Pipeline } from '~/types/domain'
|
|
7
|
+
|
|
8
|
+
/** One agent step of a pipeline as shown in a preview: its kind + whether it's a human-gated step. */
|
|
9
|
+
export interface PipelineDisplayStep {
|
|
10
|
+
kind: AgentKind
|
|
11
|
+
/** A human approval gate pauses the run after this step (`gates[i]`). */
|
|
12
|
+
gated: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The steps a pipeline preview should render: the ENABLED steps in order (a step disabled by
|
|
17
|
+
* default — `enabled[i] === false` — is skipped at run, so it would misrepresent the pipeline to
|
|
18
|
+
* list it), each flagged when it carries a human approval gate. Companions are included as their
|
|
19
|
+
* own chips, mirroring how the run timeline lists every step.
|
|
20
|
+
*/
|
|
21
|
+
export function pipelineDisplaySteps(pipeline: Pipeline): PipelineDisplayStep[] {
|
|
22
|
+
return pipeline.agentKinds
|
|
23
|
+
.map((kind, i) => ({
|
|
24
|
+
kind,
|
|
25
|
+
enabled: pipeline.enabled?.[i] !== false,
|
|
26
|
+
gated: pipeline.gates?.[i] === true,
|
|
27
|
+
}))
|
|
28
|
+
.filter((s) => s.enabled)
|
|
29
|
+
.map(({ kind, gated }) => ({ kind, gated }))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Re-exported so a picker can import the task-type gate from the same module as the
|
|
33
|
+
// launch/frame gates it composes with (the classifier itself lives in `@cat-factory/contracts`).
|
|
34
|
+
export { pipelineAllowedForTaskType }
|
|
3
35
|
|
|
4
36
|
// Surface counterpart to the backend's slice-4c run-start gate: a pipeline with a visual step
|
|
5
37
|
// (`tester-ui` / `visual-confirmation`) may run only on a frame with a UI to exercise — a
|
|
@@ -29,14 +61,21 @@ export function pipelineAllowedForFrame(
|
|
|
29
61
|
/**
|
|
30
62
|
* Whether `pipeline` may be started as a MANUAL one-off task run (the board/inspector Run menus,
|
|
31
63
|
* the add-task modal, the task run-settings default). Excludes `'recurring'`-only pipelines the
|
|
32
|
-
* backend would refuse
|
|
64
|
+
* backend would refuse, visual pipelines on a frame with no UI, and — when a `taskType` is given —
|
|
65
|
+
* pipelines whose `purpose` doesn't fit that task type (a `document` task offers only document
|
|
66
|
+
* pipelines). `taskType` omitted ⇒ no task-type restriction (an un-typed context shows all).
|
|
33
67
|
*/
|
|
34
68
|
export function pipelineAllowedForManualStart(
|
|
35
69
|
pipeline: Pipeline,
|
|
36
70
|
frame: Block | undefined,
|
|
37
71
|
blocks: readonly Block[],
|
|
72
|
+
taskType?: Block['taskType'],
|
|
38
73
|
): boolean {
|
|
39
|
-
return
|
|
74
|
+
return (
|
|
75
|
+
pipeline.availability !== 'recurring' &&
|
|
76
|
+
pipelineAllowedForFrame(pipeline, frame, blocks) &&
|
|
77
|
+
pipelineAllowedForTaskType(pipeline, taskType)
|
|
78
|
+
)
|
|
40
79
|
}
|
|
41
80
|
|
|
42
81
|
/**
|
package/i18n/locales/de.json
CHANGED
|
@@ -2936,6 +2936,17 @@
|
|
|
2936
2936
|
"emptySearchable": "Nach Titel suchen oder ein importiertes Dokument auswählen.",
|
|
2937
2937
|
"emptyRefOnly": "Fügen Sie eine Seiten-URL oder -ID ein, um sie anzuhängen."
|
|
2938
2938
|
},
|
|
2939
|
+
"repoPicker": {
|
|
2940
|
+
"searchRepoPlaceholder": "Repositorys suchen…",
|
|
2941
|
+
"clearRepo": "Repository entfernen",
|
|
2942
|
+
"searchTab": "Dateien suchen",
|
|
2943
|
+
"browseTab": "Durchsuchen",
|
|
2944
|
+
"searchFilesPlaceholder": "Dateien nach Pfad suchen…",
|
|
2945
|
+
"searchFilesHint": "Tippen, um Dateien in diesem Repository zu suchen.",
|
|
2946
|
+
"noFileMatches": "Keine passenden Dateien.",
|
|
2947
|
+
"filesFailed": "Dateien konnten nicht geladen werden: {error}",
|
|
2948
|
+
"moreFiles": "Es werden die ersten {count} Treffer angezeigt – grenzen Sie Ihre Suche ein."
|
|
2949
|
+
},
|
|
2939
2950
|
"import": {
|
|
2940
2951
|
"title": "Aus einer Dokumentquelle importieren",
|
|
2941
2952
|
"connectFirst": "Verbinden Sie zuerst eine Dokumentquelle.",
|
|
@@ -3084,6 +3095,13 @@
|
|
|
3084
3095
|
"proceed": "Trotzdem fortfahren",
|
|
3085
3096
|
"stopReset": "Aufgabe stoppen & zurücksetzen"
|
|
3086
3097
|
},
|
|
3098
|
+
"preview": {
|
|
3099
|
+
"stepCount": "{count} Schritt | {count} Schritte",
|
|
3100
|
+
"gated": "Manuelle Freigabe nach diesem Schritt"
|
|
3101
|
+
},
|
|
3102
|
+
"picker": {
|
|
3103
|
+
"noneHint": "Keine Standard-Pipeline. Du wählst beim Ausführen der Aufgabe eine aus."
|
|
3104
|
+
},
|
|
3087
3105
|
"builder": {
|
|
3088
3106
|
"title": "Pipeline-Builder",
|
|
3089
3107
|
"agentPalette": "Agenten-Palette",
|
|
@@ -3092,6 +3110,7 @@
|
|
|
3092
3110
|
"configureModels": "Modelle konfigurieren",
|
|
3093
3111
|
"configureModelsTooltip": "Modell-Presets verwalten (auf welchem Modell jeder Agent läuft)",
|
|
3094
3112
|
"namePlaceholder": "Pipeline-Name",
|
|
3113
|
+
"descriptionPlaceholder": "Beschreibung (wird bei der Pipeline-Auswahl angezeigt)",
|
|
3095
3114
|
"labelPlaceholder": "+ Label",
|
|
3096
3115
|
"gatingNeedsEstimator": "Ein Gated-Schritt benötigt davor einen Task Estimator. Fügen Sie einen hinzu, sonst wird die Pipeline nicht gespeichert.",
|
|
3097
3116
|
"emptyDraft": "Klicken Sie links auf Agenten, um eine lineare Pipeline zusammenzustellen.",
|
|
@@ -3123,6 +3142,15 @@
|
|
|
3123
3142
|
"impactThreshold": "Auswirkung ≥",
|
|
3124
3143
|
"strategy": "Strategie",
|
|
3125
3144
|
"rounds": "Runden",
|
|
3145
|
+
"purposeLabel": "Zweck",
|
|
3146
|
+
"purposePlaceholder": "Zweck auswählen",
|
|
3147
|
+
"purposeOption": {
|
|
3148
|
+
"build": "Entwicklung",
|
|
3149
|
+
"document": "Dokumentation",
|
|
3150
|
+
"review": "Review",
|
|
3151
|
+
"research": "Recherche",
|
|
3152
|
+
"planning": "Planung"
|
|
3153
|
+
},
|
|
3126
3154
|
"strategyOption": {
|
|
3127
3155
|
"specialist-panel": "Spezialisten-Panel",
|
|
3128
3156
|
"debate": "Debatte",
|
|
@@ -3176,7 +3204,8 @@
|
|
|
3176
3204
|
"skillPlaceholder": "Skill auswählen",
|
|
3177
3205
|
"skillNoneAvailable": "Keine Skills verfügbar. Verknüpfe eine Skill-Quelle in den Kontoeinstellungen.",
|
|
3178
3206
|
"skillMissing": "Dieser Skill ist nicht mehr im Katalog; wähle einen anderen.",
|
|
3179
|
-
"skillNeedsPick": "Ein Skill-Schritt braucht einen ausgewählten Skill, bevor du speichern kannst."
|
|
3207
|
+
"skillNeedsPick": "Ein Skill-Schritt braucht einen ausgewählten Skill, bevor du speichern kannst.",
|
|
3208
|
+
"purposeStepsConflict": "Dieser Zweck schreibt keinen Code und führt keine Tests aus, doch die Pipeline enthält noch Implementierungs- oder Testschritte. Entfernen Sie diese oder setzen Sie den Zweck auf Entwicklung."
|
|
3180
3209
|
},
|
|
3181
3210
|
"progress": {
|
|
3182
3211
|
"status": {
|
|
@@ -3892,6 +3921,7 @@
|
|
|
3892
3921
|
"retry": "Erneut versuchen",
|
|
3893
3922
|
"copy": "Kopieren",
|
|
3894
3923
|
"copied": "In die Zwischenablage kopiert",
|
|
3924
|
+
"copyDetails": "Details kopieren",
|
|
3895
3925
|
"reveal": "Anzeigen",
|
|
3896
3926
|
"hide": "Verbergen",
|
|
3897
3927
|
"delete": "Löschen",
|
package/i18n/locales/en.json
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"retry": "Retry",
|
|
31
31
|
"copy": "Copy",
|
|
32
32
|
"copied": "Copied to clipboard",
|
|
33
|
+
"copyDetails": "Copy details",
|
|
33
34
|
"reveal": "Reveal",
|
|
34
35
|
"hide": "Hide",
|
|
35
36
|
"delete": "Delete",
|
|
@@ -3298,6 +3299,17 @@
|
|
|
3298
3299
|
"emptySearchable": "Search by title, or pick an imported document.",
|
|
3299
3300
|
"emptyRefOnly": "Paste a page URL or ID to attach it."
|
|
3300
3301
|
},
|
|
3302
|
+
"repoPicker": {
|
|
3303
|
+
"searchRepoPlaceholder": "Search repositories…",
|
|
3304
|
+
"clearRepo": "Clear repository",
|
|
3305
|
+
"searchTab": "Search files",
|
|
3306
|
+
"browseTab": "Browse",
|
|
3307
|
+
"searchFilesPlaceholder": "Search files by path…",
|
|
3308
|
+
"searchFilesHint": "Type to search files in this repository.",
|
|
3309
|
+
"noFileMatches": "No matching files.",
|
|
3310
|
+
"filesFailed": "Couldn't load files: {error}",
|
|
3311
|
+
"moreFiles": "Showing the first {count} matches — refine your search to narrow it down."
|
|
3312
|
+
},
|
|
3301
3313
|
"import": {
|
|
3302
3314
|
"title": "Import from a document source",
|
|
3303
3315
|
"connectFirst": "Connect a document source first.",
|
|
@@ -3449,6 +3461,13 @@
|
|
|
3449
3461
|
"proceed": "Proceed anyway",
|
|
3450
3462
|
"stopReset": "Stop & reset task"
|
|
3451
3463
|
},
|
|
3464
|
+
"preview": {
|
|
3465
|
+
"stepCount": "{count} step | {count} steps",
|
|
3466
|
+
"gated": "Human approval gate after this step"
|
|
3467
|
+
},
|
|
3468
|
+
"picker": {
|
|
3469
|
+
"noneHint": "No default pipeline. You choose one when you run the task."
|
|
3470
|
+
},
|
|
3452
3471
|
"builder": {
|
|
3453
3472
|
"title": "Pipeline builder",
|
|
3454
3473
|
"agentPalette": "Agent palette",
|
|
@@ -3457,6 +3476,7 @@
|
|
|
3457
3476
|
"configureModels": "Configure models",
|
|
3458
3477
|
"configureModelsTooltip": "Manage model presets (which model each agent runs on)",
|
|
3459
3478
|
"namePlaceholder": "Pipeline name",
|
|
3479
|
+
"descriptionPlaceholder": "Description (shown when picking this pipeline)",
|
|
3460
3480
|
"labelPlaceholder": "+ label",
|
|
3461
3481
|
"gatingNeedsEstimator": "A gated step needs a Task Estimator before it. Add one or the pipeline won't save.",
|
|
3462
3482
|
"emptyDraft": "Click agents on the left to assemble a linear pipeline.",
|
|
@@ -3488,6 +3508,15 @@
|
|
|
3488
3508
|
"impactThreshold": "impact ≥",
|
|
3489
3509
|
"strategy": "Strategy",
|
|
3490
3510
|
"rounds": "Rounds",
|
|
3511
|
+
"purposeLabel": "Purpose",
|
|
3512
|
+
"purposePlaceholder": "Select a purpose",
|
|
3513
|
+
"purposeOption": {
|
|
3514
|
+
"build": "Build",
|
|
3515
|
+
"document": "Documentation",
|
|
3516
|
+
"review": "Review",
|
|
3517
|
+
"research": "Research",
|
|
3518
|
+
"planning": "Planning"
|
|
3519
|
+
},
|
|
3491
3520
|
"strategyOption": {
|
|
3492
3521
|
"specialist-panel": "Specialist panel",
|
|
3493
3522
|
"debate": "Debate",
|
|
@@ -3541,7 +3570,8 @@
|
|
|
3541
3570
|
"skillPlaceholder": "Select a skill",
|
|
3542
3571
|
"skillNoneAvailable": "No skills available. Link a skill source in account settings.",
|
|
3543
3572
|
"skillMissing": "This skill is no longer in the catalog; pick another.",
|
|
3544
|
-
"skillNeedsPick": "A Skill step needs a skill selected before you can save."
|
|
3573
|
+
"skillNeedsPick": "A Skill step needs a skill selected before you can save.",
|
|
3574
|
+
"purposeStepsConflict": "This purpose writes no code and runs no tests, but the pipeline still has implementation or testing steps. Remove them or switch the purpose to Build."
|
|
3545
3575
|
},
|
|
3546
3576
|
"progress": {
|
|
3547
3577
|
"status": {
|