diff --git a/console/package.json b/console/package.json index 1ed7551a..6c3f2eda 100644 --- a/console/package.json +++ b/console/package.json @@ -22,7 +22,7 @@ "i18next": "^25.3.2", "jcode-ui": "file:../../jcode/packages/jcode-ui", "jcode-ui-core": "file:../../jcode/packages/jcode-ui-core", - "jtype-board-react": "0.1.0", + "jtype-board-react": "github:cnjack/jtype#939d7177676376942a8059b096d2c26205472239&path:/packages/board-react", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.6.1", diff --git a/console/pnpm-lock.yaml b/console/pnpm-lock.yaml index 3f685eb8..07843c30 100644 --- a/console/pnpm-lock.yaml +++ b/console/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: specifier: file:../../jcode/packages/jcode-ui-core version: file:../../jcode/packages/jcode-ui-core(react-dom@18.3.1(react@18.3.1))(react@18.3.1) jtype-board-react: - specifier: 0.1.0 - version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: github:cnjack/jtype#939d7177676376942a8059b096d2c26205472239&path:/packages/board-react + version: https://codeload.github.com/cnjack/jtype/tar.gz/939d7177676376942a8059b096d2c26205472239#path:/packages/board-react(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -203,7 +203,6 @@ packages: '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} - hasBin: true '@babel/plugin-transform-react-jsx-self@7.29.7': resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} @@ -928,12 +927,10 @@ packages: baseline-browser-mapping@2.10.42: resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} engines: {node: '>=6.0.0'} - hasBin: true browserslist@4.28.4: resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} @@ -968,7 +965,6 @@ packages: cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} - hasBin: true cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} @@ -1044,12 +1040,10 @@ packages: esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} - hasBin: true esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} - hasBin: true escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} @@ -1187,22 +1181,20 @@ packages: jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} - hasBin: true json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} - hasBin: true - jtype-board-react@0.1.0: - resolution: {integrity: sha512-wbwYNo1gWdWCjSRQLgpdSmxEYCKLtOWSdQmKYXuXNMnSolap9835l6Ow8FMNLj1UNToe5kQx2G1OD68hbBRMUg==} + jtype-board-react@https://codeload.github.com/cnjack/jtype/tar.gz/939d7177676376942a8059b096d2c26205472239#path:/packages/board-react: + resolution: {gitHosted: true, integrity: sha512-rUotJWPkrDR0iYdDKrpiiGTv2Z4D3dQgpOc9r0DBkMNjTIcXiBYBtDYA/ncbNrpztWRUZKDfEG3Q9ts/FK427Q==, path: /packages/board-react, tarball: https://codeload.github.com/cnjack/jtype/tar.gz/939d7177676376942a8059b096d2c26205472239} + version: 0.1.2 peerDependencies: react: ^18.2.0 || ^19.0.0 react-dom: ^18.2.0 || ^19.0.0 loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1215,7 +1207,6 @@ packages: lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1228,7 +1219,6 @@ packages: marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} - hasBin: true math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -1248,7 +1238,6 @@ packages: nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} @@ -1349,7 +1338,6 @@ packages: rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -1369,7 +1357,6 @@ packages: semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1420,7 +1407,6 @@ packages: tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} - hasBin: true tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} @@ -1436,7 +1422,6 @@ packages: typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} - hasBin: true undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -1458,7 +1443,6 @@ packages: vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} @@ -1584,7 +1568,6 @@ packages: why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} - hasBin: true ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} @@ -2531,7 +2514,7 @@ snapshots: json5@2.2.3: {} - jtype-board-react@0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + jtype-board-react@https://codeload.github.com/cnjack/jtype/tar.gz/939d7177676376942a8059b096d2c26205472239#path:/packages/board-react(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) diff --git a/console/src/api/client.test.ts b/console/src/api/client.test.ts index 98fdd8c7..3ee63590 100644 --- a/console/src/api/client.test.ts +++ b/console/src/api/client.test.ts @@ -148,6 +148,22 @@ describe('httpClient — request shaping', () => { expect(JSON.parse(calls[0]!.init!.body as string)).toEqual({ prompt: 'do it' }); }); + it('encodes Card execution scope and opaque pagination cursor', async () => { + const { calls } = mockFetch(() => ({ + body: { claim: null, items: [], next_cursor: null }, + })); + const client = createHttpClient('t'); + await client.listServiceKanbanCardExecutions( + 'service/1', 'workspace 1', 'cards/payment fix.md', 'opaque+/cursor', 12, + ); + const url = new URL(calls[0]!.url, 'https://console.test'); + expect(url.pathname).toBe('/api/v1/services/service%2F1/kanban/card-executions'); + expect(url.searchParams.get('workspace_id')).toBe('workspace 1'); + expect(url.searchParams.get('document_path')).toBe('cards/payment fix.md'); + expect(url.searchParams.get('before')).toBe('opaque+/cursor'); + expect(url.searchParams.get('limit')).toBe('12'); + }); + it('stages an attachment as JSON and uploads its raw body through Cloud', async () => { const { calls } = mockFetch(({ url }) => { if (url.endsWith('/attachments/intents')) { diff --git a/console/src/api/client.ts b/console/src/api/client.ts index 8958c383..7b94ff01 100644 --- a/console/src/api/client.ts +++ b/console/src/api/client.ts @@ -48,6 +48,7 @@ import type { GitHubAppInstallation, GitHubInstallationConsentPreview, JTypePluginConnectStatus, + KanbanCardExecutionsPage, ProviderKind, PluginRepositoryResource, PluginWorkspaceResource, @@ -77,6 +78,7 @@ import type { UpdateProjectAutomationInput, PutServiceKanbanInput, ServiceKanbanBinding, + ServiceKanbanPolicy, UpdateModelInput, UpdateModelProviderInput, UpdateProjectInput, @@ -336,6 +338,8 @@ export interface ApiClient { updateProjectAutomation(projectId: string, automationId: string, input: UpdateProjectAutomationInput): Promise; deleteProjectAutomation(projectId: string, automationId: string): Promise; getServiceKanban(serviceId: string): Promise; + getServiceKanbanPolicy(serviceId: string): Promise; + listServiceKanbanCardExecutions(serviceId: string, workspaceId: string, documentPath: string, before?: string, limit?: number): Promise; putServiceKanban(serviceId: string, input: PutServiceKanbanInput): Promise; deleteServiceKanban(serviceId: string): Promise; /** Lists branches through the Service's bound Plugin; never exposes a Git credential. */ @@ -824,6 +828,18 @@ export function createHttpClient( deleteProjectAutomation: (_projectId, automationId) => req(`/automations/${encodeURIComponent(automationId)}`, { method: 'DELETE' }), getServiceKanban: (serviceId) => req(`/services/${encodeURIComponent(serviceId)}/kanban`), + getServiceKanbanPolicy: (serviceId) => req(`/services/${encodeURIComponent(serviceId)}/kanban/policy`), + listServiceKanbanCardExecutions: (serviceId, workspaceId, documentPath, before, limit = 20) => { + const params = new URLSearchParams({ + workspace_id: workspaceId, + document_path: documentPath, + limit: String(limit), + }); + if (before) params.set('before', before); + return req( + `/services/${encodeURIComponent(serviceId)}/kanban/card-executions?${params.toString()}`, + ); + }, putServiceKanban: (serviceId, input) => req(`/services/${encodeURIComponent(serviceId)}/kanban`, { method: 'PUT', body: JSON.stringify(input), }), diff --git a/console/src/api/mockClient.ts b/console/src/api/mockClient.ts index 3fdf02f5..235a5091 100644 --- a/console/src/api/mockClient.ts +++ b/console/src/api/mockClient.ts @@ -66,6 +66,8 @@ import type { ResumeSessionOptions, RunStatus, Service, + KanbanCardExecutionsPage, + ServiceKanbanPolicy, ServiceBranch, SystemInfo, UpdateClusterProviderConfigInput, @@ -2175,6 +2177,35 @@ export function createMockClient(): ApiClient { if (!spec) throw new ApiError(404, 'Kanban is not enabled'); return delay(structuredClone(spec)); }, + async getServiceKanbanPolicy(serviceId: string): Promise { + const service = [...services.values()].flat().find((item) => item.id === serviceId); + const spec = [...projectAutomations.values()].find((item) => + item.automation.service_id === serviceId && item.automation.trigger_kind === 'kanban'); + if (!service || !spec?.kanban) throw new ApiError(404, 'Kanban is not enabled'); + const plugin = [...pluginList(service.project_id).values()].find((item) => item.id === spec.kanban!.installation_id); + return delay({ + service_id: serviceId, + service_name: service.name, + repository: service.repo_owner_name ?? service.raw_repo_url ?? '', + model: { label: 'Demo model' }, + board: { workspace_id: plugin?.workspace_id ?? '', ref: spec.kanban.board_ref }, + trigger_column: { key: spec.kanban.trigger_column, label: spec.kanban.trigger_column }, + done_column: { key: spec.kanban.done_column, label: spec.kanban.done_column }, + output: spec.kanban.done_column ? 'comment_and_move_on_success' : 'comment_only', + health: { + state: spec.automation.enabled ? 'ready' : 'blocked', + blocker: spec.automation.enabled ? null : 'binding_disabled', + repair_role: spec.automation.enabled ? null : 'project_owner', + }, + }); + }, + async listServiceKanbanCardExecutions(_serviceId: string, _workspaceId: string, _documentPath: string): Promise { + return delay({ + claim: null, + items: [], + next_cursor: null, + }); + }, async putServiceKanban(serviceId, input): Promise { const projectEntry = [...services.entries()].find(([, list]) => list.some((service) => service.id === serviceId)); if (!projectEntry) throw new ApiError(404, 'service not found'); diff --git a/console/src/api/queries.ts b/console/src/api/queries.ts index dfde4fad..4a18d1d4 100644 --- a/console/src/api/queries.ts +++ b/console/src/api/queries.ts @@ -3,6 +3,7 @@ * centralised so SSE/status changes can invalidate precisely. */ import { + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -60,6 +61,9 @@ export const qk = { // button + feeds the embed modal's selector. projectBoardLinks: (projectId: string) => ['project-board-links', projectId] as const, serviceKanban: (serviceId: string) => ['service-kanban', serviceId] as const, + serviceKanbanPolicy: (serviceId: string) => ['service-kanban-policy', serviceId] as const, + serviceKanbanCardExecutions: (serviceId: string, workspaceId: string, documentPath: string) => + ['service-kanban-card-executions', serviceId, workspaceId, documentPath] as const, serviceBranches: (serviceId: string) => ['service-branches', serviceId] as const, projectPlugins: (projectId: string) => ['project-plugins', projectId] as const, projectPluginImpact: (projectId: string, installationId: string) => @@ -760,6 +764,42 @@ export function usePutServiceKanban(projectId: string, serviceId: string) { }); } +export function useServiceKanbanPolicy(serviceId: string, enabled = true) { + const api = useApi(); + return useQuery({ + queryKey: qk.serviceKanbanPolicy(serviceId), + queryFn: () => api.getServiceKanbanPolicy(serviceId), + enabled: enabled && !!serviceId, + retry: false, + }); +} + +export function useServiceKanbanCardExecutions( + serviceId: string, + workspaceId: string, + documentPath: string, + enabled = true, +) { + const api = useApi(); + return useInfiniteQuery({ + queryKey: qk.serviceKanbanCardExecutions(serviceId, workspaceId, documentPath), + queryFn: ({ pageParam }) => api.listServiceKanbanCardExecutions( + serviceId, workspaceId, documentPath, pageParam ?? undefined, + ), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + enabled: enabled && !!serviceId && !!workspaceId && !!documentPath, + retry: false, + refetchInterval: (query) => { + const pages = query.state.data?.pages; + return pages?.some((page) => page.items.some((item) => + item.status === 'received' || item.status === 'blocked' || item.status === 'queued' || item.status === 'running')) + ? 5_000 + : false; + }, + }); +} + export function useDeleteServiceKanban(projectId: string, serviceId: string) { const api = useApi(); const qc = useQueryClient(); diff --git a/console/src/api/types.ts b/console/src/api/types.ts index cfe3f804..136db8b7 100644 --- a/console/src/api/types.ts +++ b/console/src/api/types.ts @@ -263,10 +263,56 @@ export interface ProjectAutomationSpec { automation: ProjectAutomationAggregate; scm?: { automation_id?: string; branch?: string; path_pattern?: string; conclusion?: string; include_drafts?: boolean }; actions?: ScmAutomationAction[]; - kanban?: { automation_id?: string; installation_id: string; board_ref: string; trigger_column: string; done_column?: string }; + kanban?: { + automation_id?: string; + installation_id: string; + board_ref: string; + trigger_column: string; + trigger_label?: string; + done_column?: string; + done_label?: string; + }; cron?: { automation_id?: string; cron_expr: string }; } export type ServiceKanbanBinding = ProjectAutomationSpec; +export interface ServiceKanbanPolicy { + service_id: string; + service_name: string; + repository: string; + model: { id?: string; label: string }; + board: { workspace_id: string; ref: string }; + trigger_column: { key: string; label: string }; + done_column: { key?: string; label?: string }; + output: 'comment_only' | 'comment_and_move_on_success'; + health: { + state: 'ready' | 'blocked'; + blocker: string | null; + repair_role?: 'project_owner' | 'cluster_admin' | null; + }; +} +export interface KanbanCardExecution { + id: string; + status: 'received' | 'blocked' | 'queued' | 'running' | 'terminal'; + outcome?: 'succeeded' | 'failed' | 'canceled'; + summary: string; + reason: string | null; + reason_code?: string; + repair_role: 'project_owner' | 'cluster_admin' | null; + requested_actor: { label: string; precision: 'display_only' } | null; + run: { id: string; status: RunStatus; href: string } | null; + receipt: { + external: 'not_required' | 'pending' | 'written' | 'unavailable'; + writeback: 'not_required' | 'pending' | 'complete' | 'unavailable'; + }; + created_at: string; + updated_at: string; + terminal_at?: string; +} +export interface KanbanCardExecutionsPage { + claim: { document_path: string; external_ref_available: boolean } | null; + items: KanbanCardExecution[]; + next_cursor: string | null; +} export interface PutServiceKanbanInput { installation_id: string; board_ref: string; diff --git a/console/src/i18n/locales/en.ts b/console/src/i18n/locales/en.ts index 3cdafe2a..268a4d88 100644 --- a/console/src/i18n/locales/en.ts +++ b/console/src/i18n/locales/en.ts @@ -1034,6 +1034,64 @@ export default { title: 'Kanban', boardLabel: 'Board', openingBoard: 'Opening board…', + loadingPolicy: 'Loading execution policy…', + policyUnavailable: 'Execution policy could not be loaded.', + policyTrigger: 'Starts jcode when Cards enter {column}', + policyWriteback: 'Success comments and moves to {column}', + policyCommentOnly: 'Results are written as Card comments', + policyReady: 'Ready', + policyBlocked: 'Blocked · {blocker}', + policyBlockers: { + binding_disabled: 'Automation disabled', + plugin_unavailable: 'JType connection unavailable', + event_feed_unavailable: 'JType event feed unavailable', + bootstrap_unavailable: 'JType Card bootstrap unavailable', + card_index_unavailable: 'JType Card index unavailable', + card_read_unavailable: 'JType Card temporarily unreadable', + card_unavailable: 'JType Card unavailable', + board_validation_unavailable: 'JType board validation unavailable', + board_drift: 'Board or Automation column changed', + service_unavailable: 'Service unavailable', + repository_not_configured: 'Repository not configured', + repository_unavailable: 'Repository connection unavailable', + provider_unavailable: 'Repository provider unavailable', + runner_unavailable: 'Runner unavailable', + run_unavailable: 'Linked Cloud Run unavailable', + model_not_configured: 'Model not configured', + model_effort_unsupported: 'Reasoning effort unsupported', + }, + executionsTitle: 'Cloud executions', + loadingExecutions: 'Loading Cloud executions…', + executionsUnavailable: 'Cloud executions could not be loaded.', + noExecutions: 'No Cloud execution yet. Move this Card into the Starts jcode column to request one.', + executionState: { + received: 'Request received', + blocked: 'Blocked', + queued: 'Queued', + running: 'Running', + terminal: 'Finished', + succeeded: 'Succeeded', + failed: 'Failed', + canceled: 'Canceled', + }, + requestedByExternal: 'Requested by {actor}', + projectOwner: 'Project owner', + clusterAdmin: 'Cluster administrator', + openRun: 'Open Run', + writebackPending: 'Card writeback pending', + writebackUnavailable: 'Source Card unavailable', + executionSummary: { + received: 'The Card request was received.', + queued: 'The Run is queued.', + running: 'The Run is applying the requested change.', + succeeded: 'The Run completed successfully.', + failed: 'The Run failed.', + canceled: 'The Run was canceled.', + }, + priorExecutions_one: '{count} prior execution', + priorExecutions_other: '{count} prior executions', + cardUnavailable: 'This Card no longer exists in JType. Cloud execution history is preserved.', + loadEarlierExecutions: 'Load earlier executions', linkDisabledTitle: 'This Kanban automation link is disabled.', linkDisabledBody: 'The board is viewable, but card-triggered runs and writeback are disabled.', }, diff --git a/console/src/i18n/locales/ja.ts b/console/src/i18n/locales/ja.ts index f362a1a8..23db02e6 100644 --- a/console/src/i18n/locales/ja.ts +++ b/console/src/i18n/locales/ja.ts @@ -699,6 +699,64 @@ export default { title: 'Kanban', boardLabel: 'ボード', openingBoard: 'ボードを開いています…', + loadingPolicy: '実行ポリシーを読み込み中…', + policyUnavailable: '実行ポリシーを読み込めませんでした。', + policyTrigger: 'Card が {column} に入ると jcode を開始', + policyWriteback: '成功時にコメントして {column} へ移動', + policyCommentOnly: '結果を Card コメントに書き戻す', + policyReady: '実行可能', + policyBlocked: 'ブロック中 · {blocker}', + policyBlockers: { + binding_disabled: '自動化が無効', + plugin_unavailable: 'JType 接続が利用不可', + event_feed_unavailable: 'JType イベントフィードが利用不可', + bootstrap_unavailable: 'JType カードの初期化が利用不可', + card_index_unavailable: 'JType カードインデックスが利用不可', + card_read_unavailable: 'JType カードを一時的に読み取れません', + card_unavailable: 'JType カードが利用不可', + board_validation_unavailable: 'JType ボードを検証できません', + board_drift: 'ボードまたは自動化の列が変更されました', + service_unavailable: 'Service が利用不可', + repository_not_configured: 'リポジトリ未設定', + repository_unavailable: 'リポジトリ接続が利用不可', + provider_unavailable: 'リポジトリ Provider が利用不可', + runner_unavailable: 'Runner が利用不可', + run_unavailable: 'リンクされた Cloud Run が利用不可', + model_not_configured: 'モデル未設定', + model_effort_unsupported: '選択した推論強度は未対応', + }, + executionsTitle: 'Cloud 実行', + loadingExecutions: 'Cloud 実行を読み込み中…', + executionsUnavailable: 'Cloud 実行履歴を読み込めませんでした。', + noExecutions: 'Cloud 実行はまだありません。この Card を「jcode を開始」列に移動すると要求できます。', + executionState: { + received: '要求を受信', + blocked: 'ブロック中', + queued: '待機中', + running: '実行中', + terminal: '終了', + succeeded: '成功', + failed: '失敗', + canceled: 'キャンセル済み', + }, + requestedByExternal: '要求者: {actor}', + projectOwner: 'プロジェクト所有者', + clusterAdmin: 'Cluster 管理者', + openRun: 'Run を開く', + writebackPending: 'Card への書き戻し待ち', + writebackUnavailable: '元の Card は利用不可', + executionSummary: { + received: 'Card の要求を受信しました。', + queued: 'Run は待機中です。', + running: 'Run は要求された変更を実行中です。', + succeeded: 'Run は正常に完了しました。', + failed: 'Run は失敗しました。', + canceled: 'Run はキャンセルされました。', + }, + priorExecutions_one: '過去の実行 {count} 件', + priorExecutions_other: '過去の実行 {count} 件', + cardUnavailable: 'この Card は JType に存在しません。Cloud の実行履歴は保持されています。', + loadEarlierExecutions: '以前の実行を読み込む', linkDisabledTitle: 'この Kanban 自動化リンクは無効化されています。', linkDisabledBody: 'ボードは表示できますが、カードトリガー実行と書き戻しは無効化されています。', }, diff --git a/console/src/i18n/locales/ko.ts b/console/src/i18n/locales/ko.ts index 62ab6947..64420d62 100644 --- a/console/src/i18n/locales/ko.ts +++ b/console/src/i18n/locales/ko.ts @@ -699,6 +699,64 @@ export default { title: 'Kanban', boardLabel: '보드', openingBoard: '보드 여는 중…', + loadingPolicy: '실행 정책 불러오는 중…', + policyUnavailable: '실행 정책을 불러올 수 없습니다.', + policyTrigger: 'Card가 {column}에 들어가면 jcode 시작', + policyWriteback: '성공 시 댓글을 남기고 {column}(으)로 이동', + policyCommentOnly: '결과를 Card 댓글로 기록', + policyReady: '실행 가능', + policyBlocked: '차단됨 · {blocker}', + policyBlockers: { + binding_disabled: '자동화 비활성화됨', + plugin_unavailable: 'JType 연결 사용 불가', + event_feed_unavailable: 'JType 이벤트 피드 사용 불가', + bootstrap_unavailable: 'JType 카드 초기화 사용 불가', + card_index_unavailable: 'JType Card 인덱스 사용 불가', + card_read_unavailable: 'JType 카드를 일시적으로 읽을 수 없음', + card_unavailable: 'JType 카드 사용 불가', + board_validation_unavailable: 'JType 보드를 검증할 수 없음', + board_drift: '보드 또는 자동화 열이 변경됨', + service_unavailable: 'Service 사용 불가', + repository_not_configured: '저장소가 구성되지 않음', + repository_unavailable: '저장소 연결 사용 불가', + provider_unavailable: '저장소 Provider 사용 불가', + runner_unavailable: 'Runner 사용 불가', + run_unavailable: '연결된 Cloud Run 사용 불가', + model_not_configured: '모델이 구성되지 않음', + model_effort_unsupported: '선택한 추론 강도를 지원하지 않음', + }, + executionsTitle: 'Cloud 실행', + loadingExecutions: 'Cloud 실행 불러오는 중…', + executionsUnavailable: 'Cloud 실행 기록을 불러올 수 없습니다.', + noExecutions: '아직 Cloud 실행이 없습니다. 이 Card를 “jcode 시작” 열로 옮겨 요청하세요.', + executionState: { + received: '요청 수신', + blocked: '차단됨', + queued: '대기 중', + running: '실행 중', + terminal: '종료', + succeeded: '성공', + failed: '실패', + canceled: '취소됨', + }, + requestedByExternal: '요청자: {actor}', + projectOwner: '프로젝트 소유자', + clusterAdmin: 'Cluster 관리자', + openRun: 'Run 열기', + writebackPending: 'Card 라이트백 대기 중', + writebackUnavailable: '원본 Card 사용 불가', + executionSummary: { + received: 'Card 요청을 받았습니다.', + queued: 'Run이 대기 중입니다.', + running: 'Run이 요청된 변경을 수행 중입니다.', + succeeded: 'Run이 성공적으로 완료되었습니다.', + failed: 'Run이 실패했습니다.', + canceled: 'Run이 취소되었습니다.', + }, + priorExecutions_one: '이전 실행 {count}개', + priorExecutions_other: '이전 실행 {count}개', + cardUnavailable: '이 Card는 더 이상 JType에 없습니다. Cloud 실행 기록은 보존됩니다.', + loadEarlierExecutions: '이전 실행 불러오기', linkDisabledTitle: '이 Kanban 자동화 링크가 비활성화되어 있습니다.', linkDisabledBody: '보드는 볼 수 있지만 카드 트리거 실행과 라이트백은 비활성화되어 있습니다.', }, diff --git a/console/src/i18n/locales/zh-Hans.ts b/console/src/i18n/locales/zh-Hans.ts index 0793d7fc..612cd0cb 100644 --- a/console/src/i18n/locales/zh-Hans.ts +++ b/console/src/i18n/locales/zh-Hans.ts @@ -699,6 +699,64 @@ export default { title: 'Kanban', boardLabel: '看板', openingBoard: '正在打开看板…', + loadingPolicy: '正在加载执行策略…', + policyUnavailable: '无法加载执行策略。', + policyTrigger: 'Card 进入 {column} 时启动 jcode', + policyWriteback: '成功后评论并移动到 {column}', + policyCommentOnly: '结果写入 Card 评论', + policyReady: '可执行', + policyBlocked: '已阻塞 · {blocker}', + policyBlockers: { + binding_disabled: '自动化已禁用', + plugin_unavailable: 'JType 连接不可用', + event_feed_unavailable: 'JType 事件流不可用', + bootstrap_unavailable: 'JType 卡片初始化不可用', + card_index_unavailable: 'JType 卡片索引不可用', + card_read_unavailable: 'JType 卡片暂时无法读取', + card_unavailable: 'JType 卡片不可用', + board_validation_unavailable: 'JType 看板校验不可用', + board_drift: '看板或自动化列已变更', + service_unavailable: 'Service 不可用', + repository_not_configured: '尚未配置代码仓库', + repository_unavailable: '代码仓库连接不可用', + provider_unavailable: '代码仓库 Provider 不可用', + runner_unavailable: 'Runner 不可用', + run_unavailable: '关联的 Cloud Run 不可用', + model_not_configured: '尚未配置模型', + model_effort_unsupported: '模型不支持所选推理强度', + }, + executionsTitle: 'Cloud 执行', + loadingExecutions: '正在加载 Cloud 执行…', + executionsUnavailable: '无法加载 Cloud 执行记录。', + noExecutions: '尚无 Cloud 执行。将此 Card 移入“启动 jcode”列即可请求执行。', + executionState: { + received: '已收到请求', + blocked: '已阻塞', + queued: '排队中', + running: '执行中', + terminal: '已结束', + succeeded: '成功', + failed: '失败', + canceled: '已取消', + }, + requestedByExternal: '请求者:{actor}', + projectOwner: '项目所有者', + clusterAdmin: '集群管理员', + openRun: '打开 Run', + writebackPending: 'Card 回写待完成', + writebackUnavailable: '来源 Card 不可用', + executionSummary: { + received: '已收到 Card 请求。', + queued: 'Run 正在排队。', + running: 'Run 正在执行所请求的变更。', + succeeded: 'Run 已成功完成。', + failed: 'Run 执行失败。', + canceled: 'Run 已取消。', + }, + priorExecutions_one: '{count} 次历史执行', + priorExecutions_other: '{count} 次历史执行', + cardUnavailable: '此 Card 已不在 JType 中,Cloud 执行历史仍会保留。', + loadEarlierExecutions: '加载更早的执行', linkDisabledTitle: '此 Kanban 自动化链接已禁用。', linkDisabledBody: '看板可查看,但卡片触发的运行和回写已禁用。', }, diff --git a/console/src/i18n/locales/zh-Hant.ts b/console/src/i18n/locales/zh-Hant.ts index 402136fd..d3d13c06 100644 --- a/console/src/i18n/locales/zh-Hant.ts +++ b/console/src/i18n/locales/zh-Hant.ts @@ -699,6 +699,64 @@ export default { title: 'Kanban', boardLabel: '看板', openingBoard: '正在開啟看板…', + loadingPolicy: '正在載入執行策略…', + policyUnavailable: '無法載入執行策略。', + policyTrigger: 'Card 進入 {column} 時啟動 jcode', + policyWriteback: '成功後留言並移動到 {column}', + policyCommentOnly: '結果寫入 Card 留言', + policyReady: '可執行', + policyBlocked: '已阻塞 · {blocker}', + policyBlockers: { + binding_disabled: '自動化已停用', + plugin_unavailable: 'JType 連線無法使用', + event_feed_unavailable: 'JType 事件流無法使用', + bootstrap_unavailable: 'JType 卡片初始化無法使用', + card_index_unavailable: 'JType 卡片索引無法使用', + card_read_unavailable: 'JType 卡片暫時無法讀取', + card_unavailable: 'JType 卡片無法使用', + board_validation_unavailable: 'JType 看板驗證無法使用', + board_drift: '看板或自動化欄位已變更', + service_unavailable: 'Service 無法使用', + repository_not_configured: '尚未設定程式碼儲存庫', + repository_unavailable: '程式碼儲存庫連線無法使用', + provider_unavailable: '程式碼儲存庫 Provider 無法使用', + runner_unavailable: 'Runner 無法使用', + run_unavailable: '關聯的 Cloud Run 無法使用', + model_not_configured: '尚未設定模型', + model_effort_unsupported: '模型不支援所選推理強度', + }, + executionsTitle: 'Cloud 執行', + loadingExecutions: '正在載入 Cloud 執行…', + executionsUnavailable: '無法載入 Cloud 執行記錄。', + noExecutions: '尚無 Cloud 執行。將此 Card 移入「啟動 jcode」欄即可要求執行。', + executionState: { + received: '已收到要求', + blocked: '受阻', + queued: '排隊中', + running: '執行中', + terminal: '已結束', + succeeded: '成功', + failed: '失敗', + canceled: '已取消', + }, + requestedByExternal: '要求者:{actor}', + projectOwner: '專案擁有者', + clusterAdmin: 'Cluster 管理員', + openRun: '開啟 Run', + writebackPending: 'Card 回寫待完成', + writebackUnavailable: '來源 Card 無法使用', + executionSummary: { + received: '已收到 Card 要求。', + queued: 'Run 正在排隊。', + running: 'Run 正在執行所要求的變更。', + succeeded: 'Run 已成功完成。', + failed: 'Run 執行失敗。', + canceled: 'Run 已取消。', + }, + priorExecutions_one: '{count} 次歷史執行', + priorExecutions_other: '{count} 次歷史執行', + cardUnavailable: '此 Card 已不在 JType 中,Cloud 執行歷史仍會保留。', + loadEarlierExecutions: '載入更早的執行', linkDisabledTitle: '此 Kanban 自動化連結已停用。', linkDisabledBody: '看板可供檢視,但卡片觸發的執行與回寫已停用。', }, diff --git a/console/src/pages/KanbanBoardModal.module.css b/console/src/pages/KanbanBoardModal.module.css index c3b3e658..c75f43a5 100644 --- a/console/src/pages/KanbanBoardModal.module.css +++ b/console/src/pages/KanbanBoardModal.module.css @@ -19,6 +19,45 @@ min-width: 16rem; } +.policyStrip { + display: grid; + grid-template-columns: minmax(10rem, 1.3fr) repeat(4, minmax(8rem, 1fr)); + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-panel-raised); + color: var(--color-text-muted); + font-size: var(--fs-caption); + line-height: var(--lh-normal); +} + +.policyStrip[data-state='blocked'] { + border-color: var(--color-warning); +} + +.policyLead { + display: grid; + min-width: 0; +} + +.policyLead strong { + color: var(--color-text); + font-size: var(--fs-sm); +} + +.policyLead span, +.policyStrip > span { + overflow-wrap: anywhere; +} + +.policyHealth { + justify-self: end; + color: var(--color-text); + font-weight: var(--fw-semibold); +} + .setupPanel { display: grid; width: min(100%, 46rem); @@ -114,6 +153,108 @@ overflow: auto; } +.executions { + display: grid; + gap: var(--space-3); + min-width: 0; +} + +.executionCurrent { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-panel-raised); + overflow-wrap: anywhere; +} + +.executionCurrent[data-state='blocked'], +.executionCurrent[data-state='failed'], +.executionCurrent[data-state='canceled'] { + border-color: var(--color-warning); +} + +.executionCurrent[data-state='succeeded'] { + border-color: var(--color-success); +} + +.executionHeading, +.executionMeta, +.executionHistory li { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2) var(--space-3); +} + +.executionHeading { + justify-content: space-between; +} + +.executionHeading strong { + color: var(--color-text); +} + +.executionHeading time, +.executionMeta, +.executionHistory { + color: var(--color-text-dim); + font-size: var(--fs-caption); +} + +.executionCurrent p, +.executionEmpty { + margin: 0; + color: var(--color-text-muted); + font-size: var(--fs-sm); + line-height: var(--lh-normal); +} + +.executionMeta a, +.executionHistory a { + color: var(--color-accent); + font-weight: var(--fw-semibold); +} + +.executionHistory summary { + cursor: pointer; + color: var(--color-text); + font-weight: var(--fw-medium); +} + +.executionHistory ol { + display: grid; + gap: var(--space-2); + margin: var(--space-2) 0 0; + padding-left: var(--space-5); +} + +.executionLoading { + min-height: 4rem; + border-radius: var(--radius-md); + background: var(--color-panel-raised); + color: var(--color-text-dim); + font-size: var(--fs-caption); +} + +.executionError { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + color: var(--color-danger); + font-size: var(--fs-sm); +} +.executionUnavailable { + border: 1px solid color-mix(in srgb, var(--color-warning) 42%, var(--color-border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-warning) 9%, var(--color-panel)); + padding: var(--space-2) var(--space-3); + color: var(--color-text-muted); + font-size: var(--fs-caption); +} + /* Fail-visible panel: the board could not be opened (deleted / renamed / access lost). Never a blank modal (red line #1). */ .failPanel { @@ -141,6 +282,14 @@ grid-template-columns: 1fr; } + .policyStrip { + grid-template-columns: 1fr; + } + + .policyHealth { + justify-self: start; + } + .columnStatus { grid-column: auto; } diff --git a/console/src/pages/KanbanBoardModal.test.tsx b/console/src/pages/KanbanBoardModal.test.tsx index f162a631..5e62b4ce 100644 --- a/console/src/pages/KanbanBoardModal.test.tsx +++ b/console/src/pages/KanbanBoardModal.test.tsx @@ -5,6 +5,7 @@ * is a light typed Error (the proxy client + resolver depend on it). */ import { describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ApiProvider } from '../api/ApiProvider'; @@ -18,14 +19,18 @@ vi.mock('jtype-board-react', () => ({ boardRef: string; live?: boolean; readOnly?: boolean; + renderCardSupplement?: (card: { id: string; title: string }) => ReactNode; }) => ( -
+ <> +
+ {p.renderCardSupplement?.({ id: 'cards/payment.md', title: 'Payment card' })} + ), JTypeApiError: class extends Error { status: number; @@ -94,15 +99,27 @@ function makeApi( updatedClock: 2, mergeStatus: 'accepted' as const, }), + getServiceKanbanPolicy: async (serviceId: string) => ({ + service_id: serviceId, + service_name: 'Service', + repository: 'acme/service', + model: { label: 'Demo model' }, + board: { workspace_id: 'ws_team', ref: 'b_123' }, + trigger_column: { key: 'ai', label: 'AI' }, + done_column: { key: 'done', label: 'Done' }, + output: 'comment_and_move_on_success' as const, + health: { state: 'ready' as const, blocker: null }, + }), + listServiceKanbanCardExecutions: async () => ({ claim: null, items: [], next_cursor: null }), } as unknown as ApiClient; } -function renderModal(api: ApiClient, links: BoardEmbedLink[]) { +function renderModal(api: ApiClient, links: BoardEmbedLink[], serviceId?: string) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( - {}} /> + {}} /> , ); @@ -163,6 +180,133 @@ describe('KanbanBoardModal', () => { expect(screen.queryByTestId('kanban-board-select')).toBeNull(); }); + it('shows the execution policy and blocked receipt inside native Card details', async () => { + const listExecutions = vi.fn(async () => ({ + claim: { document_path: 'cards/payment.md', external_ref_available: true }, + items: [{ + id: 'occ_1', + status: 'blocked' as const, + summary: 'Execution is blocked', + reason: 'Choose an allowed model for this Service.', + reason_code: 'model_not_configured', + repair_role: 'project_owner' as const, + requested_actor: { label: 'External editor', precision: 'display_only' as const }, + run: null, + receipt: { external: 'written' as const, writeback: 'not_required' as const }, + created_at: '2026-07-31T00:00:00Z', + updated_at: '2026-07-31T00:00:00Z', + }], + next_cursor: null, + })); + const api = { + ...makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }), + getServiceKanbanPolicy: async () => ({ + service_id: 'svc_1', + service_name: 'payments-api', + repository: 'acme/payments', + model: { id: 'model_1', label: 'Claude Sonnet' }, + board: { workspace_id: 'ws_team', ref: 'b_123' }, + trigger_column: { key: 'ai', label: 'Agent queue' }, + done_column: { key: 'done', label: 'Done' }, + output: 'comment_and_move_on_success' as const, + health: { state: 'ready' as const, blocker: null }, + }), + listServiceKanbanCardExecutions: listExecutions, + } as unknown as ApiClient; + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + {}} + /> + + , + ); + + expect((await screen.findByTestId('kanban-policy')).textContent).toContain('payments-api'); + expect(screen.getByTestId('kanban-policy').textContent).toContain('Agent queue'); + const receipt = await screen.findByTestId('kanban-execution-current'); + expect(receipt.textContent).toContain('Model not configured'); + expect(receipt.textContent).toContain('Project owner'); + expect(receipt.textContent).not.toContain('Card writeback pending'); + expect(listExecutions).toHaveBeenCalledWith('svc_1', 'ws_team', 'cards/payment.md', undefined); + }); + + it('keeps execution load failures visible and retries into the empty state', async () => { + const listExecutions = vi.fn() + .mockRejectedValueOnce(new Error('temporary API failure')) + .mockResolvedValueOnce({ claim: null, items: [], next_cursor: null }); + const api = { + ...makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }), + listServiceKanbanCardExecutions: listExecutions, + } as unknown as ApiClient; + renderModal(api, [link()], 'svc_1'); + + expect(await screen.findByText('Cloud executions could not be loaded.')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByText(/Move this Card into the Starts jcode column/)).toBeTruthy(); + expect(listExecutions).toHaveBeenCalledTimes(2); + }); + + it('preserves deleted-Card history and loads older occurrences by opaque cursor', async () => { + const listExecutions = vi.fn(async (_serviceId: string, _workspaceId: string, _path: string, before?: string) => ( + before + ? { + claim: { document_path: 'cards/payment.md', external_ref_available: false }, + items: [{ + id: 'occ_old', + status: 'terminal' as const, + outcome: 'failed', + summary: 'Run failed', + reason: null, + repair_role: null, + requested_actor: null, + run: null, + receipt: { external: 'written' as const, writeback: 'complete' as const }, + created_at: '2026-07-30T00:00:00Z', + updated_at: '2026-07-30T00:01:00Z', + }], + next_cursor: null, + } + : { + claim: { document_path: 'cards/payment.md', external_ref_available: false }, + items: [{ + id: 'occ_current', + status: 'terminal' as const, + outcome: 'succeeded', + summary: 'Run completed successfully', + reason: null, + repair_role: null, + requested_actor: null, + run: null, + receipt: { external: 'written' as const, writeback: 'complete' as const }, + created_at: '2026-07-31T00:00:00Z', + updated_at: '2026-07-31T00:01:00Z', + }], + next_cursor: 'opaque-before', + } + )); + const api = { + ...makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }), + listServiceKanbanCardExecutions: listExecutions, + } as unknown as ApiClient; + renderModal(api, [link()], 'svc_1'); + + expect(await screen.findByText(/no longer exists in JType/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Load earlier executions' })); + expect(await screen.findByText('1 prior execution')).toBeTruthy(); + expect(listExecutions).toHaveBeenLastCalledWith( + 'svc_1', + 'ws_team', + 'cards/payment.md', + 'opaque-before', + ); + }); + it('resolves board_ref (config id) → relativePath before rendering', async () => { const api = makeApi({ ws_team: [ diff --git a/console/src/pages/KanbanBoardModal.tsx b/console/src/pages/KanbanBoardModal.tsx index d0470ade..9824e02a 100644 --- a/console/src/pages/KanbanBoardModal.tsx +++ b/console/src/pages/KanbanBoardModal.tsx @@ -21,6 +21,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; import { JTypeApiError, JTypeBoard, type BoardLocale } from 'jtype-board-react'; import 'jtype-board-react/style.css'; import { useApi } from '../api/ApiProvider'; @@ -30,6 +31,8 @@ import { usePluginBoards, useProjectPlugins, usePutServiceKanban, + useServiceKanbanCardExecutions, + useServiceKanbanPolicy, } from '../api/queries'; import { Button } from '../components/Button'; import { Modal } from '../components/Modal'; @@ -37,7 +40,7 @@ import { SelectField } from '../components/Field'; import { LoadingBlock } from '../components/States'; import { makeBoardProxyClient } from '../kanban/boardProxyClient'; import { resolveBoardPathById } from '../kanban/resolveBoardPathById'; -import type { BoardEmbedLink, PluginBoardResource } from '../api/types'; +import type { BoardEmbedLink, KanbanCardExecution, PluginBoardResource } from '../api/types'; import styles from './KanbanBoardModal.module.css'; /** Map the browser locale to a board-supported one; default 'en'. */ @@ -129,6 +132,173 @@ interface Props { onClose: () => void; } +function KanbanPolicyStrip({ serviceId }: { serviceId: string }) { + const { t } = useTranslation(); + const policy = useServiceKanbanPolicy(serviceId, !!serviceId); + if (!serviceId) return null; + if (policy.isLoading) { + return
{t('kanban.loadingPolicy')}
; + } + if (policy.isError || !policy.data) { + return ( +
+ {t('kanban.policyUnavailable')} + +
+ ); + } + const value = policy.data; + const blocker = value.health.blocker + ? t(`kanban.policyBlockers.${value.health.blocker}`, { defaultValue: value.health.blocker }) + : ''; + return ( +
+
+ {value.service_name} + {value.repository} +
+ + {t('kanban.policyTrigger', { + column: value.trigger_column.label || value.trigger_column.key, + })} + + {value.model.label} + + {value.done_column.key + ? t('kanban.policyWriteback', { column: value.done_column.label || value.done_column.key }) + : t('kanban.policyCommentOnly')} + + + {value.health.state === 'ready' + ? t('kanban.policyReady') + : ( + <> + {t('kanban.policyBlocked', { blocker })} + {value.health.repair_role === 'project_owner' && ` · ${t('kanban.projectOwner')}`} + {value.health.repair_role === 'cluster_admin' && ` · ${t('kanban.clusterAdmin')}`} + + )} + +
+ ); +} + +function executionStateLabel(execution: KanbanCardExecution, t: TFunction): string { + if (execution.status === 'terminal' && execution.outcome) { + return t(`kanban.executionState.${execution.outcome}`); + } + return t(`kanban.executionState.${execution.status}`); +} + +function executionDescription(execution: KanbanCardExecution, t: TFunction): string { + if (execution.status === 'blocked' && execution.reason_code) { + return t(`kanban.policyBlockers.${execution.reason_code}`, { + defaultValue: execution.reason ?? execution.summary, + }); + } + const key = execution.status === 'terminal' && execution.outcome + ? execution.outcome + : execution.status; + return t(`kanban.executionSummary.${key}`, { defaultValue: execution.summary }); +} + +function CardExecutionsSupplement({ + serviceId, + workspaceId, + documentPath, +}: { + serviceId: string; + workspaceId: string; + documentPath: string; +}) { + const { t } = useTranslation(); + const query = useServiceKanbanCardExecutions(serviceId, workspaceId, documentPath); + if (query.isLoading) { + return
{t('kanban.loadingExecutions')}
; + } + if (query.isError) { + return ( +
+ {t('kanban.executionsUnavailable')} + +
+ ); + } + const pages = query.data?.pages ?? []; + const executions = pages.flatMap((page) => page.items); + const claim = pages[0]?.claim ?? null; + if (executions.length === 0) { + return

{t('kanban.noExecutions')}

; + } + const current = executions[0]!; + const history = executions.slice(1); + return ( +
+ {claim && !claim.external_ref_available && ( +
+ {t('kanban.cardUnavailable')} +
+ )} +
+
+ {executionStateLabel(current, t)} + +
+

{executionDescription(current, t)}

+
+ {current.requested_actor && ( + {t('kanban.requestedByExternal', { actor: current.requested_actor.label })} + )} + {current.repair_role === 'project_owner' && {t('kanban.projectOwner')}} + {current.repair_role === 'cluster_admin' && {t('kanban.clusterAdmin')}} + {current.run && {t('kanban.openRun')}} + {current.receipt.writeback === 'pending' && {t('kanban.writebackPending')}} + {current.receipt.writeback === 'unavailable' && {t('kanban.writebackUnavailable')}} +
+
+ {history.length > 0 && ( +
+ {t('kanban.priorExecutions', { count: history.length })} +
    + {history.map((execution) => ( +
  1. + {executionStateLabel(execution, t)} + + {execution.run && {t('kanban.openRun')}} +
  2. + ))} +
+
+ )} + {query.hasNextPage && ( + + )} +
+ ); +} + export function KanbanBoardModal({ projectId, serviceId = '', links, canManage = false, onClose }: Props) { const { t } = useTranslation(); const api = useApi(); @@ -290,6 +460,7 @@ export function KanbanBoardModal({ projectId, serviceId = '', links, canManage = />
)} + {link && serviceId && } {!link ? null : resolved.isPending ? ( @@ -402,6 +573,13 @@ export function KanbanBoardModal({ projectId, serviceId = '', links, canManage = boardRef={resolved.data} live={false} locale={boardLocale()} + renderCardSupplement={serviceId ? (card) => ( + + ) : undefined} />
diff --git a/design/README.md b/design/README.md index 2a8ef9a6..495498fd 100644 --- a/design/README.md +++ b/design/README.md @@ -37,6 +37,7 @@ icons, and small prototype-only interactions live under `assets/`. | `kanban-link-flow-connect.html` | 项目 Kanban 设置(候选)未连接态:jtype 集成登录卡 + 看板设置锁定 | | `kanban-link-flow-pick.html` | 项目 Kanban 设置(候选)已连接态:服务与工作区/看板/列全部点选,单一当前看板 | | `kanban-link-flow-expired.html` | 项目 Kanban 设置(候选)凭据过期态:看板设置收回,重新登录后恢复 | +| `kanban-agent-executions.html` | Service Kanban 执行策略、内嵌 jtype Card detail supplement 与 accepted/blocked/succeeded/error receipt 状态 | | `device-list.html` | Cloud console device list, populated and empty states | | `device-welcome.html` | Cloud console device detail: new-session composer and session list | | `device-session.html` | Cloud console remote session: live and device-offline states | diff --git a/design/assets/kanban-agent-executions.css b/design/assets/kanban-agent-executions.css new file mode 100644 index 00000000..6895def2 --- /dev/null +++ b/design/assets/kanban-agent-executions.css @@ -0,0 +1,399 @@ +/* Feature Loop 1 — policy, embedded jtype board, and Card execution supplement. + Uses the shared prototype tokens; sample data lives in the page only. */ + +.ka-main { + position: relative; + min-height: 0; + height: calc(100dvh - 3.25rem); + overflow: hidden; + padding: 1rem 1.25rem 1.25rem; + background: var(--surface); +} + +.ka-heading, +.ka-state-nav, +.ka-policy, +.ka-board-toolbar, +.ka-column > header, +.ka-card footer, +.ka-dialog-head, +.ka-section-head, +.ka-executions > header, +.ka-executions > header > span, +.ka-history summary { + display: flex; + align-items: center; +} + +.ka-heading { + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.ka-heading h1 { + margin-top: 0.125rem; + font-size: 1.35rem; + letter-spacing: -0.035em; +} + +.ka-state-nav { + position: relative; + z-index: 7; + gap: 0.35rem; + margin-bottom: 0.75rem; + color: var(--text-faint); + font-size: 0.6875rem; +} + +.ka-state-nav > span { margin-right: 0.25rem; } +.ka-state-nav button, +.ka-view-tabs button, +.ka-compact-control { + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + padding: 0.25rem 0.55rem; + color: var(--text-dim); + cursor: pointer; +} + +.ka-state-nav button:hover, +.ka-view-tabs button:hover, +.ka-compact-control:hover { background: var(--surface-hover); color: var(--text); } +.ka-state-nav button[aria-pressed="true"] { + border-color: var(--border-strong); + background: var(--surface-raised); + box-shadow: var(--shadow); + color: var(--text); + font-weight: 650; +} + +.ka-policy { + position: relative; + z-index: 1; + gap: 1rem; + min-height: 4.25rem; + margin-bottom: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 0.75rem 0.875rem; + background: var(--surface-raised); + box-shadow: var(--shadow); +} + +.ka-policy-lead { + display: flex; + align-items: center; + min-width: 17rem; + gap: 0.7rem; +} + +.ka-policy-lead > span:last-child, +.ka-receipt > span:last-child { display: grid; gap: 0.1rem; } +.ka-policy-lead strong { font-size: 0.75rem; } +.ka-policy-lead small, +.ka-receipt small { color: var(--text-muted); font-size: 0.625rem; } +.ka-policy-icon { + display: grid; + width: 2rem; + height: 2rem; + flex: none; + place-items: center; + border-radius: var(--radius-md); + background: var(--accent-soft); + color: var(--accent); +} + +.ka-policy-facts { + display: grid; + flex: 1; + grid-template-columns: repeat(4, minmax(6rem, 1fr)); + gap: 0.5rem 1rem; +} + +.ka-policy-facts div { display: grid; min-width: 0; gap: 0.05rem; } +.ka-policy-facts dt, +.ka-execution-meta dt { color: var(--text-faint); font-size: 0.625rem; } +.ka-policy-facts dd { + overflow: hidden; + margin: 0; + color: var(--text-dim); + font-size: 0.6875rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ka-policy-status { flex: none; gap: 0.3rem; } +.ka-policy[data-ka-policy="blocked"] .ka-policy-icon { background: var(--warning-bg); color: var(--warning); } + +.ka-board-shell { + position: relative; + height: calc(100% - 9.2rem); + min-height: 22rem; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface-soft); +} + +.ka-board-toolbar { + min-height: 2.8rem; + gap: 0.5rem; + border-bottom: 1px solid var(--border-soft); + padding: 0.45rem 0.75rem; + background: color-mix(in srgb, var(--surface) 86%, transparent); +} + +.ka-view-tabs { display: inline-flex; border-radius: var(--radius-md); background: var(--surface-inset); padding: 0.15rem; } +.ka-view-tabs button { font-size: 0.625rem; } +.ka-view-tabs button[aria-pressed="true"] { background: var(--surface); box-shadow: var(--shadow); color: var(--text); font-weight: 650; } +.ka-toolbar-spacer { flex: 1; } +.ka-compact-control { border-color: var(--border); font-size: 0.625rem; } +.ka-search { + display: flex; + align-items: center; + width: 11rem; + gap: 0.35rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.3rem 0.5rem; + background: var(--surface); + color: var(--text-faint); +} +.ka-search input { width: 100%; border: 0; outline: 0; background: transparent; font-size: 0.625rem; } + +.ka-board { + display: grid; + height: calc(100% - 2.8rem); + grid-template-columns: repeat(4, minmax(12rem, 1fr)); + gap: 0.65rem; + overflow: auto; + padding: 0.65rem; +} + +.ka-column { + min-width: 12rem; + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + padding: 0.45rem; + background: color-mix(in srgb, var(--surface-inset) 30%, transparent); +} + +.ka-column[data-trigger-column] { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + background: color-mix(in srgb, var(--accent-soft) 30%, var(--surface-soft)); +} + +.ka-column > header { gap: 0.4rem; min-height: 1.8rem; padding: 0 0.25rem 0.35rem; color: var(--text-muted); font-size: 0.625rem; } +.ka-column > header strong { color: var(--text-dim); font-size: 0.6875rem; } +.ka-column > header em { margin-left: auto; color: var(--accent); font-size: 0.5625rem; font-style: normal; font-weight: 650; } +.ka-column-dot { width: 0.45rem; height: 0.45rem; border-radius: 99px; background: var(--text-faint); } +.ka-column-dot[data-tone="accent"] { background: var(--accent); } +.ka-column-dot[data-tone="info"] { background: var(--info); } +.ka-column-dot[data-tone="success"] { background: var(--success); } + +.ka-card { + display: grid; + gap: 0.4rem; + margin-bottom: 0.45rem; + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + padding: 0.65rem; + background: var(--surface); + box-shadow: var(--shadow); +} +.ka-card[data-selected] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.ka-card-ticket { color: var(--text-faint); font-family: var(--font-mono); font-size: 0.5625rem; } +.ka-card h2 { font-size: 0.6875rem; line-height: 1.35; } +.ka-card p { color: var(--text-muted); font-size: 0.5625rem; line-height: 1.45; } +.ka-card footer { justify-content: space-between; color: var(--text-faint); font-size: 0.5625rem; } +.ka-priority { color: var(--danger); } +.ka-done { color: var(--success); } +.ka-polling { + position: absolute; + right: 0.55rem; + bottom: 0.45rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--border-soft); + border-radius: 99px; + padding: 0.15rem 0.45rem; + background: color-mix(in srgb, var(--surface) 90%, transparent); + color: var(--text-faint); + font-size: 0.5625rem; +} +.ka-polling span { width: 0.35rem; height: 0.35rem; border-radius: 99px; background: var(--text-faint); } + +.ka-dialog-backdrop { + position: absolute; + z-index: 5; + inset: 0; + background: color-mix(in srgb, var(--text) 18%, transparent); + backdrop-filter: blur(1px); +} + +.ka-card-dialog { + position: absolute; + z-index: 6; + inset: 6.5rem 2.25rem 1.8rem; + display: flex; + min-width: 0; + flex-direction: column; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--text) 9%, var(--border)); + border-radius: var(--radius-xl); + background: var(--surface); + box-shadow: var(--shadow-float); +} + +.ka-dialog-head { + min-height: 2.9rem; + justify-content: space-between; + border-bottom: 1px solid var(--border-soft); + padding: 0.5rem 0.8rem 0.5rem 1.4rem; +} +.ka-dialog-grid { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(0, 1fr) 20rem; } +.ka-card-content { min-width: 0; overflow: auto; padding: 1.35rem 2rem 2.5rem; } +.ka-title-input { + width: 100%; + margin-bottom: 1.35rem; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.035em; +} +.ka-section-head { gap: 0.65rem; margin-bottom: 0.45rem; } +.ka-section-head h2, +.ka-native-section h2, +.ka-properties h2, +.ka-executions h2 { font-size: 0.6875rem; } +.ka-section-head h2 { margin-right: auto; } +.ka-section-head span { color: var(--text-faint); font-size: 0.5625rem; } +.ka-card-content textarea { + width: 100%; + min-height: 10rem; + resize: vertical; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.8rem; + background: var(--surface-soft); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.625rem; + line-height: 1.6; +} +.ka-native-section { display: grid; gap: 0.5rem; margin-top: 1.35rem; border-top: 1px solid var(--border-soft); padding-top: 1rem; color: var(--text-dim); font-size: 0.625rem; } +.ka-native-section h2 span { margin-left: 0.35rem; color: var(--text-faint); font-weight: 500; } +.ka-native-section label { display: flex; align-items: center; gap: 0.45rem; } + +.ka-inspector { + min-width: 0; + overflow: auto; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--surface-soft) 75%, var(--surface)); +} +.ka-properties { padding: 1.15rem 1.15rem 1rem; } +.ka-properties dl { display: grid; gap: 0.15rem; margin-top: 0.65rem; } +.ka-properties dl > div { display: grid; min-height: 2.1rem; grid-template-columns: 5.3rem minmax(0, 1fr); align-items: center; gap: 0.4rem; } +.ka-properties dt { color: var(--text-muted); font-size: 0.625rem; } +.ka-properties dd { min-width: 0; margin: 0; } +.ka-properties button { width: 100%; overflow: hidden; border: 0; border-radius: var(--radius-sm); background: transparent; padding: 0.3rem 0.4rem; color: var(--text-dim); text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.ka-properties button:hover { background: var(--surface); } + +.ka-executions { + border-top: 1px solid var(--border); + padding: 1rem 1.15rem 1.35rem; +} +.ka-executions > header { justify-content: space-between; gap: 0.5rem; margin-bottom: 0.75rem; } +.ka-executions > header > span { gap: 0.4rem; } +.ka-executions > header > span .icon { color: var(--accent); } +.ka-executions > header small { color: var(--text-faint); font-size: 0.5625rem; } +.ka-execution-state { display: grid; gap: 0.65rem; } +.ka-receipt { display: flex; align-items: center; gap: 0.6rem; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 0.65rem; } +.ka-receipt[data-tone="info"] { border-color: color-mix(in srgb, var(--info) 35%, var(--border)); background: var(--info-bg); } +.ka-receipt[data-tone="warning"] { border-color: color-mix(in srgb, var(--warning) 38%, var(--border)); background: var(--warning-bg); } +.ka-receipt[data-tone="success"] { border-color: color-mix(in srgb, var(--success) 35%, var(--border)); background: var(--success-bg); } +.ka-receipt[data-tone="danger"] { border-color: color-mix(in srgb, var(--danger) 38%, var(--border)); background: var(--danger-bg); } +.ka-receipt strong { font-size: 0.6875rem; } +.ka-receipt-icon { display: grid; width: 1.65rem; height: 1.65rem; flex: none; place-items: center; border-radius: 99px; background: color-mix(in srgb, var(--surface) 72%, transparent); color: currentColor; font-weight: 700; } +.ka-spinner { width: 0.75rem; height: 0.75rem; border: 2px solid color-mix(in srgb, var(--info) 25%, transparent); border-top-color: var(--info); border-radius: 99px; animation: ka-spin 900ms linear infinite; } +@keyframes ka-spin { to { transform: rotate(360deg); } } +.ka-receipt-copy { color: var(--text-muted); font-size: 0.625rem; line-height: 1.55; } +.ka-run-link { width: 100%; min-width: 0; justify-content: flex-start; overflow: hidden; font-size: 0.625rem; } +.ka-run-link .mono { margin-left: auto; overflow: hidden; color: var(--text-faint); text-overflow: ellipsis; } +.ka-next-action { display: grid; gap: 0.15rem; border-left: 2px solid var(--warning); padding-left: 0.55rem; color: var(--text-muted); font-size: 0.625rem; } +.ka-next-action strong { color: var(--warning); font-size: 0.5625rem; text-transform: uppercase; letter-spacing: 0.06em; } +.ka-execution-meta { display: grid; gap: 0.3rem; } +.ka-execution-meta > div { display: flex; justify-content: space-between; gap: 0.5rem; } +.ka-execution-meta dd { margin: 0; color: var(--text-dim); font-size: 0.625rem; text-align: right; } +.ka-execution-meta dd span { display: block; color: var(--text-faint); font-size: 0.5625rem; } +.ka-history { margin-top: 0.8rem; border-top: 1px solid var(--border); padding-top: 0.65rem; } +.ka-history summary { justify-content: space-between; color: var(--text-muted); font-size: 0.625rem; cursor: pointer; } +.ka-history summary span:last-child { border-radius: 99px; background: var(--surface-inset); padding: 0.05rem 0.35rem; } +.ka-history a { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.45rem; margin-top: 0.5rem; border-radius: var(--radius-md); padding: 0.45rem; font-size: 0.5625rem; } +.ka-history a:hover { background: var(--surface); } +.ka-history a > span:nth-child(2) { display: grid; } +.ka-history small { color: var(--text-faint); } +.ka-history-dot { width: 0.4rem; height: 0.4rem; border-radius: 99px; background: var(--danger); } + +@media (max-width: 1000px) { + .ka-policy-facts { grid-template-columns: repeat(2, minmax(6rem, 1fr)); } + .ka-policy-facts div:nth-child(n + 3) { display: none; } + .ka-card-dialog { inset-inline: 1rem; } + .ka-dialog-grid { grid-template-columns: minmax(0, 1fr) 18rem; } +} + +@media (max-width: 720px) { + .ka-main { + height: auto; + min-height: calc(100dvh - 3.25rem); + overflow: auto; + padding: 0.75rem; + } + .ka-heading { position: relative; } + .ka-state-nav { + position: fixed; + z-index: 8; + top: 3.5rem; + right: 0.35rem; + left: 0.35rem; + overflow-x: auto; + margin: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.35rem; + background: color-mix(in srgb, var(--surface) 96%, transparent); + box-shadow: var(--shadow); + white-space: nowrap; + } + .ka-policy { align-items: flex-start; flex-wrap: wrap; } + .ka-policy-lead { width: 100%; min-width: 0; } + .ka-policy-facts { width: 100%; flex-basis: 100%; grid-template-columns: 1fr 1fr; } + .ka-policy-facts div { display: grid !important; } + .ka-policy-status { position: absolute; top: 0.75rem; right: 0.75rem; } + .ka-policy-lead > span:last-child { padding-right: 6rem; } + .ka-board-shell { height: 28rem; } + .ka-board { grid-template-columns: repeat(4, 15rem); } + .ka-search { width: 8.5rem; } + .ka-dialog-backdrop { position: fixed; } + .ka-card-dialog { + position: fixed; + inset: 6.15rem 0.35rem 0.35rem; + border-radius: var(--radius-lg); + } + .ka-dialog-grid { display: block; overflow: auto; } + .ka-card-content { overflow: visible; padding: 1.1rem 1rem 1.5rem; } + .ka-title-input { font-size: 1.1rem; } + .ka-inspector { overflow: visible; border-top: 1px solid var(--border); border-left: 0; } + .ka-executions { padding-bottom: 2rem; } +} + +@media (prefers-reduced-motion: reduce) { + .ka-spinner { animation: none; } +} diff --git a/design/assets/kanban-agent-executions.js b/design/assets/kanban-agent-executions.js new file mode 100644 index 00000000..e1f837f5 --- /dev/null +++ b/design/assets/kanban-agent-executions.js @@ -0,0 +1,40 @@ +(() => { + const buttons = [...document.querySelectorAll("[data-ka-state-button]")]; + const panels = [...document.querySelectorAll("[data-ka-state-panel]")]; + const policy = document.querySelector("[data-ka-policy]"); + const policyStatus = policy?.querySelector(".ka-policy-status"); + const policyTitle = policy?.querySelector("#policyTitle"); + const policyHint = policy?.querySelector(".ka-policy-lead small"); + + const setState = (state) => { + buttons.forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.kaStateButton === state)); + }); + panels.forEach((panel) => { + panel.hidden = panel.dataset.kaStatePanel !== state; + }); + + const blocked = state === "blocked"; + if (policy) policy.dataset.kaPolicy = blocked ? "blocked" : "ready"; + if (policyTitle) { + policyTitle.textContent = blocked + ? "拖入 Agent queue 会被记录,但当前无法执行" + : "拖入 Agent queue 即请求 Cloud Agent 执行"; + } + if (policyHint) { + policyHint.textContent = blocked + ? "缺少可用模型;同一次请求会在修复后继续,不需要重新拖卡。" + : "每次有效进入只受理一次;列内编辑和重扫不会重复运行。"; + } + if (policyStatus) { + policyStatus.dataset.tone = blocked ? "warning" : "success"; + policyStatus.innerHTML = blocked + ? '模型未配置' + : '可以执行'; + } + }; + + buttons.forEach((button) => { + button.addEventListener("click", () => setState(button.dataset.kaStateButton)); + }); +})(); diff --git a/design/kanban-agent-executions.html b/design/kanban-agent-executions.html new file mode 100644 index 00000000..5e84b28f --- /dev/null +++ b/design/kanban-agent-executions.html @@ -0,0 +1,310 @@ + + + + + + + + + Kanban execution receipts — jcode Cloud design + + + + + + + +
+ + +
+
+ +
+ sample fixture · Loop 1 + +
+
+ +
+
+
+ payments-api · jtype / Payments delivery +

Kanban

+
+ +
+ + + +
+
+ + + 拖入 Agent queue 即请求 Cloud Agent 执行 + 每次有效进入只受理一次;列内编辑和重扫不会重复运行。 + +
+
+
执行目标
payments-api
+
Repository
acme/payments
+
模型
Claude Sonnet 4.5
+
成功后
评论并移至 Done
+
+ + + 可以执行 + +
+ +
+
+
+ + + +
+
+ + +
+ +
+
+
Backlog2
+
+ PAY-48 +

Document refund idempotency contract

+
highWei
+
+
+ PAY-52 +

Expose settlement timeline to support

+
medium
+
+
+ +
+
Agent queue2触发 Cloud
+
+ PAY-41 +

Make payout retry idempotent

+

Duplicate callbacks must converge on one ledger update.

+
urgentJack
+
+
+ PAY-47 +

Validate webhook signing clock skew

+
highMei
+
+
+ +
+
In progress1
+
+ PAY-38 +

Retry dead-letter reconciliation

+
highYao
+
+
+ +
+
Done3
+
+ PAY-36 +

Trace gateway request IDs

+
completedJack
+
+
+
+ + Polling every 30s +
+ + + +
+
+
+ + diff --git a/docs/23-kanban-execution-receipts.md b/docs/23-kanban-execution-receipts.md new file mode 100644 index 00000000..d0970c68 --- /dev/null +++ b/docs/23-kanban-execution-receipts.md @@ -0,0 +1,425 @@ +# Kanban execution receipts + +Status: implementation contract for Feature Loop 1 + +Parent product contract: [22-jtype-agent-work-prd.md](22-jtype-agent-work-prd.md) + +Prototype: [../design/kanban-agent-executions.html](../design/kanban-agent-executions.html) + +## Outcome + +Moving a jtype Card into a Service Kanban trigger column becomes an observable, +idempotent request for Cloud execution: + +1. Cloud durably records the transition before it attempts dispatch. +2. The Card immediately shows whether the request was accepted or blocked. +3. The same event, a bootstrap rescan, or an edit while the Card remains in the + trigger column cannot create another Run. +4. A Card that leaves and later re-enters after the prior execution and + writeback are terminal creates a new execution without losing the permanent + Card-to-Service relationship. +5. Both the embedded board and an external jtype client can understand the + result. The embedded detail uses a Cloud projection; the external Card gets + a marker-backed comment. + +The Card remains the Work Item truth. A Run remains execution truth. No Run +state is copied into Card frontmatter. + +## Scope + +This loop implements: + +- Service Kanban policy preview and dependency health; +- a durable jtype event cursor with a one-time level bootstrap; +- permanent Card claim anchors and repeatable transition occurrences; +- accepted, blocked, active-run, failed, canceled, succeeded, and + writeback-pending receipts; +- idempotent Card comments and terminal writeback; +- a paginated Card executions API; +- a `jtype-board-react` Card-detail supplement slot; +- the embedded Cloud executions panel, loading/empty/error/blocked/history + states, Run links, and a keyboard-accessible path. + +This loop deliberately does not implement: + +- stable requested/accountable identity resolution beyond honest actor + snapshots; that is Loop 2; +- generic Automation history or Cron/SCM output; that is Loop 3; +- token/cost aggregation; that is Loop 4; +- a pre-move blocking confirmation. `jtype-board-react` does not currently + expose an optimistic-lock-safe pre-save/cancel hook. The policy preview and + receipt are therefore non-blocking and server-authoritative. + +## Invariants + +1. `(automation_id, document_id)` identifies one permanent claim anchor. +2. One valid transition into the trigger column identifies one occurrence. +3. One occurrence owns at most one Run. +4. One effective jtype event key owns at most one occurrence for an Automation. +5. A blocked occurrence is resumed in place after repair; it is not replaced. +6. An active occurrence prevents a concurrent Run for the same claim even if + the Card leaves and re-enters. +7. A new occurrence is allowed only after: + - the claim was observed outside the trigger column; + - the previous occurrence is terminal; and + - any required terminal writeback is complete. +8. A comment projection is idempotent by occurrence and receipt phase. +9. Disabling or deleting the binding stops new occurrences but does not erase + claims, occurrences, frozen routing, or in-flight writeback. +10. `editedBy` is display text, not a Cloud principal or authorization input. + +## State model + +### Trigger cursor + +`automation_kanban_triggers` gains: + +| Field | Meaning | +| --- | --- | +| `event_cursor` | Last fully applied durable jtype board event sequence | +| `bootstrapped_at` | Non-null after the one-time level scan has established initial state | + +The poller consumes events in sequence order and advances the cursor only after +the event has been durably classified. On first adoption of this contract it +performs one level scan: + +- Cards outside the trigger column establish `last_observed_column`; +- Cards already inside establish one `bootstrap::` + occurrence; +- subsequent ticks consume only durable events plus retryable work. + +A board that cannot provide events is a visible `event_feed_unavailable` +binding blocker; repeatedly scanning levels is not an acceptable steady state. +Board drift or transient validation failure fences bootstrap and new event +consumption only. Existing occurrences and receipt retries continue through +their frozen document/writeback route, so drift cannot strand already accepted +work. + +### Claim anchor + +`automation_kanban_claims` remains keyed by `(automation_id, document_id)` and +stores stable external identity plus re-entry state: + +| Field | Meaning | +| --- | --- | +| existing frozen installation/workspace/path/done fields | Writeback route that survives later binding deletion | +| `last_observed_column` | Latest classified Card column | +| `outside_trigger_at` | Latest observation outside the trigger column | +| `latest_occurrence_id` | Convenience pointer, not the occurrence identity | +| `external_ref_available` | False after an authoritative Card-not-found response | +| `updated_at` | Last state observation | + +The historical `run_id` and `writeback_at` columns remain readable during the +transition, then are treated as compatibility projections of the latest +occurrence. New behavior never uses `claim.run_id != null` as a permanent +“already ran forever” switch. + +### Occurrence and receipt + +Migration `0058` adds `automation_kanban_occurrences`: + +| Field | Contract | +| --- | --- | +| `id` | Stable Cloud occurrence id | +| `automation_id`, `document_id` | Permanent claim key snapshot | +| `event_key` | Unique per Automation; `event:` or deterministic bootstrap key | +| `event_sequence` | Nullable for bootstrap | +| `actor_display` | Untrusted jtype display snapshot only | +| `entry_column` | Trigger column snapshot | +| `state` | `received`, `blocked`, `queued`, `running`, `terminal` | +| `outcome` | Empty until terminal, then `succeeded`, `failed`, or `canceled` | +| `reason_code`, `reason_message` | Typed blocker/failure and safe guidance | +| `repair_role` | `project_owner`, `cluster_admin`, or empty | +| `run_id` | Nullable, unique when present | +| `receipt_phase` | Latest required external comment phase: `accepted`, `blocked`, `already_running`, `writeback_pending`, or `terminal` | +| `receipt_written_at` | Latest external receipt projection | +| `writeback_state` | `not_required`, `pending`, `complete`, `unavailable` | +| `writeback_error` | Safe last error, never a credential or model payload | +| frozen installation/workspace/path/done fields | Route for later receipt/writeback | +| timestamps | `created_at`, `updated_at`, `terminal_at` | + +Required constraints: + +- unique `(automation_id, event_key)`; +- unique `run_id` where non-null; +- foreign key to Run uses `ON DELETE SET NULL`; +- occurrence rows do not cascade with Automation, Service, Project, or Plugin + deletion; historical execution and frozen writeback must survive. + +Receipt is the observable state of the occurrence, not another independently +mutable aggregate. The API derives receipt status from occurrence fields. + +### Transition classifier + +| Observation | Result | +| --- | --- | +| First bootstrap inside trigger | Create claim + bootstrap occurrence | +| Enter trigger with unseen sequence, no active occurrence, prior writeback complete | Create occurrence | +| Replay of the same sequence | Return existing occurrence | +| Edit while still in trigger | Update claim observation; no occurrence | +| Level rescan after bootstrap | No occurrence | +| Leave trigger | Mark claim outside; keep occurrence | +| Re-enter while prior Run active | No Run; project `already_running` receipt | +| Re-enter after prior terminal but writeback pending | No Run; project `writeback_pending` receipt | +| Re-enter after prior terminal + writeback complete + observed leave | Create occurrence | +| Dependency missing before dispatch | Same occurrence becomes blocked | +| Dependency repaired | Resume same blocked occurrence | + +The classifier is one deep module used by event consumption, bootstrap, and +blocked retry. Handlers do not reproduce these rules. + +## Dispatch and receipt order + +For a valid entry: + +1. Lock or atomically compare the claim. +2. Insert/get the occurrence by event key. +3. Persist `received` before any external or Run side effect. +4. Resolve the binding, Service, repository, model, runner capacity, and jtype + writeback route. +5. If a dependency is absent, persist `blocked` with a typed reason and repair + role; enqueue/retry its Card comment. +6. Otherwise create/get the Run using occurrence id as its idempotency key, + attach it to the occurrence, and persist `queued`. +7. Write the accepted receipt comment. A temporary comment failure does not + roll back the accepted Run; it leaves receipt writeback pending. +8. Reconciliation projects Run state into the occurrence. +9. Terminal reconciliation writes a terminal comment. Success may then move + the Card to the frozen done column; failure and cancel never move it. +10. Only after all required terminal projections succeed is writeback complete. + +Blocked dependencies are retried from stored occurrence state even without a +new board event. Model/provider/runner failures remain visible and never produce +fake Run success. + +## External Card comment + +Each phase has a stable, hidden marker: + +```html + +``` + +The visible comment is concise: + +> jcode Cloud accepted this Card for `payments-api` using `claude-sonnet`. +> Run: `run_123`. + +Blocked comments name the dependency and next owner: + +> jcode Cloud could not start this Card: no model is configured for the +> Service. Project owner: choose an allowed model; cluster admin: configure the +> provider. + +Before creating a comment the client checks existing Card comments for the +exact marker. A timeout after a remote success therefore retries safely. Comment +creation success and Card movement are tracked separately. + +## HTTP contract + +### Policy + +`GET /api/v1/services/{service_id}/kanban/policy` + +Member-readable response: + +```json +{ + "service_id": "svc_123", + "service_name": "payments-api", + "repository": "acme/payments", + "model": {"id": "model_123", "label": "Claude Sonnet"}, + "board": {"workspace_id": "ws_123", "ref": "jcode.board"}, + "trigger_column": {"key": "ai", "label": "Agent queue"}, + "done_column": {"key": "done", "label": "Done"}, + "output": "comment_and_move_on_success", + "health": { + "state": "ready", + "blocker": null + } +} +``` + +Unavailable dependencies return a successful policy projection with +`health.state = "blocked"` and a typed blocker when the binding itself exists. +Missing/unauthorized resources continue to use the shared typed error envelope. +The UI must not replace a blocked policy with a healthy-looking default. + +### Card executions + +`GET /api/v1/services/{service_id}/kanban/card-executions` + +Query: + +- `workspace_id` required; +- `document_path` required because `BoardViewCard.id` is the relative path in + `jtype-board-react`; +- `before` optional opaque cursor; +- `limit` defaults to 20 and is capped. + +The server resolves the binding and claim. The browser never supplies an +Automation id or document id as authority. + +Response: + +```json +{ + "claim": { + "document_path": "jcode/fix-retry.md", + "external_ref_available": true + }, + "items": [ + { + "id": "occ_123", + "status": "running", + "summary": "Run is applying the requested change", + "reason": null, + "repair_role": null, + "requested_actor": {"label": "Jack", "precision": "display_only"}, + "run": { + "id": "run_123", + "status": "running", + "href": "/runs/run_123" + }, + "receipt": {"external": "written", "writeback": "pending"}, + "created_at": "2026-07-31T02:00:00Z" + } + ], + "next_cursor": null +} +``` + +Items use newest-first fixed ordering `(created_at DESC, id DESC)`. They expose +safe summaries, not prompts, model bodies, Plugin tokens, or raw jtype events. +An absent claim returns `items: []`; it is not an error. An authoritative +deleted Card returns preserved history with `external_ref_available: false`. + +## jtype package contract + +`jtype-board-react` adds: + +```ts +type JTypeBoardProps = { + renderCardSupplement?: (card: BoardViewCard) => ReactNode +} +``` + +The function is invoked only for the built-in editable Card detail and its +result is rendered in a dedicated, labelled-neutral slot beneath native +Properties. It is not invoked when `onCardOpen` intercepts the detail. + +Shared board components receive the value through slots: + +- `BoardSurfaceProps.renderCardSupplement`; +- `BoardPeekProps.supplement`. + +No platform check is added. Omission preserves Desktop, Web, package bundle, +and existing hosts. The slot cannot block or mutate native title, description, +status, relations, comments, or Card save behavior. + +The package README, public `.d.ts`, built bundle, fixture, and Playwright tests +are part of the release. Feature impact: + +| Surface | Effect | +| --- | --- | +| Desktop | No visible change when prop omitted | +| Web | No visible change when prop omitted | +| `jtype-board-react` editable embed | Optional supplement in native detail | +| read-only embed | Unchanged | +| intercepted `onCardOpen` | Unchanged; host owns the detail | + +## Embedded UX contract + +Above the board, the policy strip always answers: + +- which column requests execution; +- Service and repository; +- selected model; +- terminal output; +- whether the path is currently ready or blocked. + +Inside the Card detail, the supplement is ordered for triage: + +1. current receipt state and the next action; +2. Run link, or blocker and responsible role; +3. truthful request actor snapshot; +4. collapsed prior occurrences. + +Loading uses a small labelled skeleton. Empty says that no Cloud execution has +been requested and tells the user which column triggers one. API error remains +visible with Retry. A blocked result is not styled as progress or success. + +Keyboard users can use the board's existing status selector to move a Card and +can tab to Retry, Run, and history disclosure. The supplement uses semantic +status/alert regions and never relies on color alone. + +At narrow width, the native Card detail already stacks its inspector below the +editor. The supplement stays inside that inspector; current receipt precedes +history and technical ids wrap instead of widening the dialog. + +## Test design + +### jtype red-green cases + +1. Editable embed opens native Card editor and renders host supplement. +2. Native Description and Properties remain usable with the supplement. +3. Omitted supplement leaves Desktop/Web/embed output unchanged. +4. `onCardOpen` interception does not invoke or render the supplement. +5. Read-only detail remains unchanged. +6. Supplement survives bounded desktop and narrow viewport layouts. + +### Store contract cases + +Run against Memory and PostgreSQL: + +1. first entry creates one claim and one occurrence; +2. event replay returns the same occurrence; +3. in-column edit and post-bootstrap level scan create nothing; +4. leave + terminal + writeback complete + re-enter creates a second occurrence; +5. active or writeback-pending re-entry does not create a Run; +6. blocked retry resumes the same occurrence; +7. concurrent consumers converge on one occurrence and one Run; +8. Automation/Service/Plugin deletion preserves run-bound history and frozen + writeback; +9. Card deletion preserves history and marks the external reference unavailable; +10. event cursor advances only after durable classification. + +### Poller and reconciler cases + +1. ordered events, bootstrap, board drift, event feed unavailable; +2. model/provider/repository/runner gates produce typed blocked receipts; +3. accepted and terminal comments use stable markers; +4. comment timeout-after-success retries without duplication; +5. success comment then optional move; failure/cancel comment without move; +6. writeback partial failure remains pending and retries; +7. disabled binding stops new entries but completes frozen writeback. + +### API and Console cases + +1. Viewer/Member can read policy/history; outsider cannot. +2. document path is resolved only within the requested Service binding. +3. fixed ordering and cursor pagination are stable. +4. policy ready/blocked and Card panel loading/empty/error/blocked/running/ + terminal/deleted-source states render truthfully. +5. Run links target the bound Project. +6. keyboard-only status move and receipt inspection. + +### E2E journey + +The repository-owned fixture must prove: + +1. external or embedded jtype Card enters the trigger column; +2. one occurrence, one accepted receipt, and one Run appear; +3. an in-column edit and replay do not add another; +4. terminal reconciliation comments and moves on success; +5. leaving then re-entering creates exactly a second occurrence. + +## Delivery boundaries + +1. jtype PR: additive Card supplement public contract, docs, package artifacts, + Desktop/Web/package verification. +2. Cloud PR: this design, schema/state machine/poller/reconciler/API/Console/E2E. +3. Cloud depends on a reproducible published or immutable jtype package version; + a machine-local tarball path is never committed. +4. Kimi CLI and Grok CLI review both PRs against PRD AC1 before final test and + push. Confirmed findings are fixed, then the reviews are rerun. diff --git a/docs/README.md b/docs/README.md index 864346a5..0106cbd5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ | [19-account-settings-sync.md](19-account-settings-sync.md) | 多 Desktop Provider 配置 E2EE 同步 + Cloud Provider Proxy | | [20-unified-connections.md](20-unified-connections.md) | 统一 Connection 概念(kanban/gitea/github/gitlab)需求分析与对抗审查 | | [22-jtype-agent-work-prd.md](22-jtype-agent-work-prd.md) | jtype Card ↔ Agent Run 协作闭环、身份、自动化与 Usage PRD | +| [23-kanban-execution-receipts.md](23-kanban-execution-receipts.md) | Kanban transition、occurrence、receipt、Card executions API 与 UI 实现合同 | 可视化蓝图(v1,早于存储/BYOK 细化): diff --git a/orchestrator/internal/api/api.go b/orchestrator/internal/api/api.go index 90ec2016..2bfb54d7 100644 --- a/orchestrator/internal/api/api.go +++ b/orchestrator/internal/api/api.go @@ -486,6 +486,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("PATCH /api/v1/automations/{aid}", s.authed(s.handleUpdatePluginAutomation)) mux.Handle("DELETE /api/v1/automations/{aid}", s.authed(s.handleDeletePluginAutomation)) mux.Handle("GET /api/v1/services/{id}/kanban", s.authed(s.handleGetServiceKanban)) + mux.Handle("GET /api/v1/services/{id}/kanban/policy", s.authed(s.handleGetServiceKanbanPolicy)) + mux.Handle("GET /api/v1/services/{id}/kanban/card-executions", s.authed(s.handleGetServiceKanbanCardExecutions)) mux.Handle("PUT /api/v1/services/{id}/kanban", s.authed(s.handlePutServiceKanban)) mux.Handle("DELETE /api/v1/services/{id}/kanban", s.authed(s.handleDeleteServiceKanban)) mux.Handle("GET /api/v1/projects/{id}/kanban/board/links", s.authed(s.handleListBoardEmbedLinks)) diff --git a/orchestrator/internal/api/service_kanban.go b/orchestrator/internal/api/service_kanban.go index d982809a..759ab0a9 100644 --- a/orchestrator/internal/api/service_kanban.go +++ b/orchestrator/internal/api/service_kanban.go @@ -1,13 +1,17 @@ package api import ( + "encoding/base64" + "encoding/json" "errors" "net/http" + "strconv" "strings" "time" "github.com/cnjack/jcloud/internal/domain" "github.com/cnjack/jcloud/internal/jtype" + "github.com/cnjack/jcloud/internal/modelcfg" "github.com/cnjack/jcloud/internal/store" ) @@ -73,6 +77,412 @@ func (s *Server) handleGetServiceKanban(w http.ResponseWriter, r *http.Request) writeJSON(w, 200, spec) } +type serviceKanbanPolicyView struct { + ServiceID string `json:"service_id"` + ServiceName string `json:"service_name"` + Repository string `json:"repository"` + Model struct { + ID string `json:"id,omitempty"` + Label string `json:"label"` + } `json:"model"` + Board struct { + WorkspaceID string `json:"workspace_id"` + Ref string `json:"ref"` + } `json:"board"` + TriggerColumn struct { + Key string `json:"key"` + Label string `json:"label"` + } `json:"trigger_column"` + DoneColumn struct { + Key string `json:"key,omitempty"` + Label string `json:"label,omitempty"` + } `json:"done_column"` + Output string `json:"output"` + Health struct { + State string `json:"state"` + Blocker *string `json:"blocker"` + RepairRole *string `json:"repair_role"` + } `json:"health"` +} + +func (s *Server) handleGetServiceKanbanPolicy(w http.ResponseWriter, r *http.Request) { + svc, spec, ok := s.loadServiceKanban(w, r, domain.RoleViewer) + if !ok { + return + } + if spec == nil || spec.Kanban == nil { + writeError(w, http.StatusNotFound, "not_found", "Kanban is not enabled for this Service") + return + } + view := serviceKanbanPolicyView{ + ServiceID: svc.ID, ServiceName: svc.Name, Repository: svc.RepoOwnerName, + Output: "comment_and_move_on_success", + } + if view.Repository == "" { + view.Repository = svc.RawRepoURL + } + view.Board.Ref = spec.Kanban.BoardRef + view.TriggerColumn.Key = spec.Kanban.TriggerColumn + view.TriggerColumn.Label = spec.Kanban.TriggerLabel + if view.TriggerColumn.Label == "" { + view.TriggerColumn.Label = spec.Kanban.TriggerColumn + } + view.DoneColumn.Key = spec.Kanban.DoneColumn + view.DoneColumn.Label = spec.Kanban.DoneLabel + if view.DoneColumn.Label == "" { + view.DoneColumn.Label = spec.Kanban.DoneColumn + } + view.Health.State = "ready" + if spec.Kanban.DoneColumn == "" { + view.Output = "comment_only" + } + + block := func(code, repairRole string) { + if view.Health.State == "ready" { + view.Health.State = "blocked" + value := code + view.Health.Blocker = &value + if repairRole != "" { + role := repairRole + view.Health.RepairRole = &role + } + } + } + if !spec.Automation.Enabled { + block("binding_disabled", "project_owner") + } + installation, err := s.st.GetPluginInstallation(r.Context(), spec.Kanban.InstallationID) + if err != nil || installation.Provider != domain.PluginJType || + installation.Status != domain.PluginStatusEnabled || installation.LastHealthError != "" || + installation.WorkspaceID == "" || !installation.TokenSet() { + block("plugin_unavailable", "project_owner") + } else { + view.Board.WorkspaceID = installation.WorkspaceID + cfg, cfgErr := s.st.GetProviderConfig(r.Context(), domain.PluginJType) + if cfgErr != nil || !cfg.PluginEnabled || strings.TrimSpace(cfg.BaseURL) == "" || + cfg.ConfigRevision != installation.ConfigRevision { + block("plugin_unavailable", "cluster_admin") + } + } + if strings.HasPrefix(spec.Automation.LastError, "event_feed_unavailable:") { + block("event_feed_unavailable", "project_owner") + } + if strings.HasPrefix(spec.Automation.LastError, "bootstrap_unavailable:") { + block("bootstrap_unavailable", "project_owner") + } + if strings.HasPrefix(spec.Automation.LastError, "card_index_unavailable:") { + block("card_index_unavailable", "project_owner") + } + if strings.HasPrefix(spec.Automation.LastError, "board_validation_unavailable:") { + block("board_validation_unavailable", "project_owner") + } + if strings.HasPrefix(spec.Automation.LastError, "board_drift:") { + block("board_drift", "project_owner") + } + if blocker, repairRole := s.serviceKanbanRepositoryBlocker(r, svc); blocker != "" { + block(blocker, repairRole) + } + if s.cfg.DisableK8s { + block("runner_unavailable", "cluster_admin") + } + selection, outcome, selectErr := s.models.SelectModel( + r.Context(), svc.ProjectID, deref(svc.DefaultModelID), spec.Automation.ModelID, + ) + if selectErr != nil || outcome != modelcfg.SelectOK { + block("model_not_configured", "project_owner") + view.Model.Label = "Not configured" + } else { + view.Model.ID = selection.ModelID + view.Model.Label = selection.ModelName + if !selection.SupportsEffort(spec.Automation.ModelEffort) { + block("model_effort_unsupported", "project_owner") + } + } + writeJSON(w, http.StatusOK, view) +} + +func (s *Server) serviceKanbanRepositoryBlocker(r *http.Request, svc *domain.Service) (string, string) { + if svc == nil || svc.DeletingAt != nil { + return "service_unavailable", "project_owner" + } + switch svc.RepoKind { + case domain.RepoKindRaw: + if strings.TrimSpace(svc.RawRepoURL) == "" { + return "repository_not_configured", "project_owner" + } + return "", "" + case domain.RepoKindProvider: + if !domain.ValidProvider(svc.Provider) || strings.TrimSpace(svc.RepoOwnerName) == "" { + return "repository_not_configured", "project_owner" + } + default: + return "repository_not_configured", "project_owner" + } + binding, err := s.st.GetServiceRepositoryBinding(r.Context(), svc.ID) + if err != nil || binding.InstallationID == "" || strings.TrimSpace(binding.CloneURL) == "" { + return "repository_unavailable", "project_owner" + } + installation, err := s.st.GetPluginInstallation(r.Context(), binding.InstallationID) + if err != nil || installation.Provider != domain.ProviderKind(svc.Provider) || + installation.Status != domain.PluginStatusEnabled || installation.LastHealthError != "" { + return "provider_unavailable", "project_owner" + } + if (installation.Provider == domain.PluginGitHub && installation.GitHubInstallID == "") || + (installation.Provider != domain.PluginGitHub && !installation.TokenSet()) { + return "provider_unavailable", "project_owner" + } + cfg, err := s.st.GetProviderConfig(r.Context(), installation.Provider) + if err != nil || !cfg.PluginEnabled || strings.TrimSpace(cfg.BaseURL) == "" || + cfg.ConfigRevision != installation.ConfigRevision { + return "provider_unavailable", "cluster_admin" + } + return "", "" +} + +func (s *Server) handleGetServiceKanbanCardExecutions(w http.ResponseWriter, r *http.Request) { + svc, spec, ok := s.loadServiceKanban(w, r, domain.RoleViewer) + if !ok { + return + } + if spec == nil || spec.Kanban == nil { + writeError(w, http.StatusNotFound, "not_found", "Kanban is not enabled for this Service") + return + } + workspaceID := strings.TrimSpace(r.URL.Query().Get("workspace_id")) + documentPath := strings.TrimSpace(r.URL.Query().Get("document_path")) + if workspaceID == "" || documentPath == "" { + writeError(w, http.StatusBadRequest, "bad_request", "workspace_id and document_path are required") + return + } + installation, err := s.st.GetPluginInstallation(r.Context(), spec.Kanban.InstallationID) + if err != nil || installation.WorkspaceID != workspaceID { + writeError(w, http.StatusNotFound, "not_found", "Card is not part of this Service Kanban") + return + } + limit := 20 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + value, parseErr := strconv.Atoi(raw) + if parseErr != nil || value < 1 || value > 50 { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be between 1 and 50") + return + } + limit = value + } + before, cursorErr := decodeKanbanOccurrenceCursor(strings.TrimSpace(r.URL.Query().Get("before"))) + if cursorErr != nil { + writeError(w, http.StatusBadRequest, "invalid_cursor", "before is not a valid Card execution cursor") + return + } + claim, err := s.st.GetPluginKanbanClaimByPath( + r.Context(), spec.Automation.ID, workspaceID, documentPath, + ) + if errors.Is(err, store.ErrNotFound) { + writeJSON(w, http.StatusOK, serviceKanbanExecutionsView{ + Items: []serviceKanbanExecutionItem{}, + }) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not load Card execution claim") + return + } + occurrences, err := s.st.ListPluginKanbanCardExecutions( + r.Context(), spec.Automation.ID, svc.ID, workspaceID, documentPath, before, limit+1, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not load Card executions") + return + } + var nextCursor *string + if len(occurrences) > limit { + occurrences = occurrences[:limit] + value := encodeKanbanOccurrenceCursor(occurrences[len(occurrences)-1]) + nextCursor = &value + } + items := make([]serviceKanbanExecutionItem, 0, len(occurrences)) + for i := range occurrences { + occurrence := &occurrences[i] + var run *domain.Run + if occurrence.RunID == "" { + items = append(items, serviceKanbanExecutionView(*occurrence, nil)) + continue + } + run, err = s.st.GetRun(r.Context(), occurrence.RunID) + if err != nil && !errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusInternalServerError, "internal", "could not load Card execution Run") + return + } + items = append(items, serviceKanbanExecutionView(*occurrence, run)) + } + writeJSON(w, http.StatusOK, serviceKanbanExecutionsView{ + Claim: &serviceKanbanClaimView{ + DocumentPath: claim.DocumentPath, ExternalRefAvailable: claim.ExternalRefAvailable, + }, + Items: items, NextCursor: nextCursor, + }) +} + +type kanbanOccurrenceCursorEnvelope struct { + CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` +} + +func encodeKanbanOccurrenceCursor(occurrence domain.PluginKanbanOccurrence) string { + payload, _ := json.Marshal(kanbanOccurrenceCursorEnvelope{ + CreatedAt: occurrence.CreatedAt.UTC(), ID: occurrence.ID, + }) + return base64.RawURLEncoding.EncodeToString(payload) +} + +func decodeKanbanOccurrenceCursor(raw string) (*store.PluginKanbanOccurrenceCursor, error) { + if raw == "" { + return nil, nil + } + payload, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, err + } + var envelope kanbanOccurrenceCursorEnvelope + if err = json.Unmarshal(payload, &envelope); err != nil { + return nil, err + } + if envelope.CreatedAt.IsZero() || strings.TrimSpace(envelope.ID) == "" { + return nil, errors.New("cursor fields are required") + } + return &store.PluginKanbanOccurrenceCursor{ + CreatedAt: envelope.CreatedAt, ID: envelope.ID, + }, nil +} + +type serviceKanbanClaimView struct { + DocumentPath string `json:"document_path"` + ExternalRefAvailable bool `json:"external_ref_available"` +} + +type serviceKanbanRequestedActorView struct { + Label string `json:"label"` + Precision string `json:"precision"` +} + +type serviceKanbanExecutionRunView struct { + ID string `json:"id"` + Status domain.RunStatus `json:"status"` + Href string `json:"href"` +} + +type serviceKanbanExecutionReceiptView struct { + External string `json:"external"` + Writeback string `json:"writeback"` +} + +type serviceKanbanExecutionItem struct { + ID string `json:"id"` + Status domain.KanbanOccurrenceState `json:"status"` + Outcome string `json:"outcome,omitempty"` + Summary string `json:"summary"` + Reason *string `json:"reason"` + ReasonCode string `json:"reason_code,omitempty"` + RepairRole *string `json:"repair_role"` + RequestedActor *serviceKanbanRequestedActorView `json:"requested_actor"` + Run *serviceKanbanExecutionRunView `json:"run"` + Receipt serviceKanbanExecutionReceiptView `json:"receipt"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TerminalAt *time.Time `json:"terminal_at,omitempty"` +} + +type serviceKanbanExecutionsView struct { + Claim *serviceKanbanClaimView `json:"claim"` + Items []serviceKanbanExecutionItem `json:"items"` + NextCursor *string `json:"next_cursor"` +} + +func serviceKanbanExecutionView(occurrence domain.PluginKanbanOccurrence, run *domain.Run) serviceKanbanExecutionItem { + status := occurrence.State + outcome := occurrence.Outcome + terminalAt := occurrence.TerminalAt + reasonCode := occurrence.ReasonCode + reasonMessage := occurrence.ReasonMessage + repairRoleCode := occurrence.RepairRole + var runView *serviceKanbanExecutionRunView + if run != nil { + switch { + case run.Status.Terminal(): + status = domain.KanbanOccurrenceTerminal + outcome = string(run.Status) + terminalAt = run.FinishedAt + case run.Status == domain.StatusRunning || run.Status == domain.StatusAwaitingInput: + status = domain.KanbanOccurrenceRunning + default: + status = domain.KanbanOccurrenceQueued + } + runView = &serviceKanbanExecutionRunView{ + ID: run.ID, Status: run.Status, Href: "/runs/" + run.ID, + } + } else if occurrence.RunID != "" && status != domain.KanbanOccurrenceTerminal { + // Runs are the execution truth while they exist. If a retained Card + // occurrence points at a Run that is no longer readable, do not fall + // back to a stale queued/received state that looks healthy. + status = domain.KanbanOccurrenceBlocked + reasonCode = "run_unavailable" + reasonMessage = "The linked Cloud Run is no longer available." + repairRoleCode = "project_owner" + } + summary := "Card entry received" + switch status { + case domain.KanbanOccurrenceBlocked: + summary = "Execution is blocked" + case domain.KanbanOccurrenceQueued: + summary = "Run is queued" + case domain.KanbanOccurrenceRunning: + summary = "Run is applying the requested change" + case domain.KanbanOccurrenceTerminal: + switch outcome { + case string(domain.StatusSucceeded): + summary = "Run completed successfully" + case string(domain.StatusCanceled): + summary = "Run was canceled" + default: + summary = "Run failed" + } + } + var reason *string + if reasonMessage != "" { + value := reasonMessage + reason = &value + } + var repairRole *string + if repairRoleCode != "" { + value := repairRoleCode + repairRole = &value + } + var actor *serviceKanbanRequestedActorView + if occurrence.ActorDisplay != "" { + actor = &serviceKanbanRequestedActorView{ + Label: occurrence.ActorDisplay, Precision: "display_only", + } + } + externalReceipt := "not_required" + if occurrence.WritebackState == "unavailable" { + externalReceipt = "unavailable" + } else if occurrence.ReceiptPhase != "" { + externalReceipt = "pending" + if occurrence.ReceiptWrittenAt != nil { + externalReceipt = "written" + } + } + return serviceKanbanExecutionItem{ + ID: occurrence.ID, Status: status, Outcome: outcome, Summary: summary, + Reason: reason, ReasonCode: reasonCode, RepairRole: repairRole, + RequestedActor: actor, Run: runView, + Receipt: serviceKanbanExecutionReceiptView{ + External: externalReceipt, Writeback: occurrence.WritebackState, + }, + CreatedAt: occurrence.CreatedAt, UpdatedAt: occurrence.UpdatedAt, + TerminalAt: terminalAt, + } +} + func (s *Server) handlePutServiceKanban(w http.ResponseWriter, r *http.Request) { svc, current, ok := s.loadServiceKanban(w, r, domain.RoleMember) if !ok { @@ -129,6 +539,8 @@ func (s *Server) handlePutServiceKanban(w http.ResponseWriter, r *http.Request) return } canonicalBoardRef := "" + triggerLabel := triggerColumn + doneLabel := doneColumn roundTripCurrent := current != nil && current.Kanban != nil && current.Kanban.InstallationID == req.InstallationID && current.Kanban.BoardRef == req.BoardRef && @@ -139,6 +551,12 @@ func (s *Server) handlePutServiceKanban(w http.ResponseWriter, r *http.Request) // round-trip that value without performing an unbounded board-document // scan; the poller remains responsible for detecting later board drift. canonicalBoardRef = current.Kanban.BoardRef + if current.Kanban.TriggerLabel != "" { + triggerLabel = current.Kanban.TriggerLabel + } + if current.Kanban.DoneLabel != "" { + doneLabel = current.Kanban.DoneLabel + } } else { // New or changed bindings use a document path/name. A canonical-looking // b_* value is only accepted when it exactly matches the current binding. @@ -165,6 +583,8 @@ func (s *Server) handlePutServiceKanban(w http.ResponseWriter, r *http.Request) writeError(w, 409, "column_not_found", "done_column '"+doneColumn+"' is not a column on board "+req.BoardRef) return } + triggerLabel = boardColumnLabel(board, triggerColumn) + doneLabel = boardColumnLabel(board, doneColumn) } items, err := s.st.ListPluginAutomationsByProject(r.Context(), svc.ProjectID) if err != nil { @@ -187,7 +607,11 @@ func (s *Server) handlePutServiceKanban(w http.ResponseWriter, r *http.Request) } now := time.Now().UTC() a := &domain.PluginAutomation{ID: domain.NewID(), ServiceID: svc.ID, InstallationID: req.InstallationID, Name: "Kanban", TriggerKind: "kanban", PromptTemplate: "Complete the task described by the JType card.", Enabled: enabled, IgnoreJCode: true, CreatedBy: principalFrom(r.Context()).userID(), CreatedAt: now} - trigger := &domain.KanbanTrigger{AutomationID: a.ID, InstallationID: req.InstallationID, BoardRef: canonicalBoardRef, TriggerColumn: triggerColumn, DoneColumn: doneColumn} + trigger := &domain.KanbanTrigger{ + AutomationID: a.ID, InstallationID: req.InstallationID, BoardRef: canonicalBoardRef, + TriggerColumn: triggerColumn, TriggerLabel: triggerLabel, + DoneColumn: doneColumn, DoneLabel: doneLabel, + } if current == nil { if err := s.st.CreatePluginAutomation(r.Context(), a, nil, nil, trigger, nil); err != nil { if errors.Is(err, store.ErrAlreadyExists) { @@ -238,3 +662,18 @@ func (s *Server) handleDeleteServiceKanban(w http.ResponseWriter, r *http.Reques } w.WriteHeader(http.StatusNoContent) } + +func boardColumnLabel(board *jtype.Board, key string) string { + if board == nil || key == "" { + return "" + } + for _, column := range board.Columns { + if column.Key == key { + if strings.TrimSpace(column.Name) != "" { + return column.Name + } + return column.Key + } + } + return key +} diff --git a/orchestrator/internal/api/service_kanban_test.go b/orchestrator/internal/api/service_kanban_test.go index 676c30d7..dc0891a1 100644 --- a/orchestrator/internal/api/service_kanban_test.go +++ b/orchestrator/internal/api/service_kanban_test.go @@ -68,14 +68,256 @@ func newServiceKanbanServer(t *testing.T) (*httptest.Server, *store.MemStore, *S return ts, st, srv } +func TestServiceKanbanPolicyAndCardExecutions(t *testing.T) { + st := store.NewMemStore() + srv := New(st, &config.Config{ConsoleToken: consoleToken, MasterKey: validTokenKey(t)}, + slog.New(slog.NewTextHandler(io.Discard, nil)), sse.NewHub(), nil) + ts := httptest.NewServer(srv.Handler()) + registerTestServerStore(t, ts, st) + t.Cleanup(ts.Close) + ctx := context.Background() + project := &domain.Project{ID: "receipt-project", Name: "Receipt project"} + service := &domain.Service{ + ID: "receipt-service", ProjectID: project.ID, Name: "payments-api", + RepoKind: domain.RepoKindProvider, Provider: domain.ProviderGitea, + RepoOwnerName: "acme/payments", DefaultBranch: "main", + } + if err := st.CreateProject(ctx, project); err != nil { + t.Fatal(err) + } + if err := st.CreateService(ctx, service); err != nil { + t.Fatal(err) + } + repositoryProvider := &domain.ProviderConfig{ + Provider: domain.PluginGitea, BaseURL: "https://gitea.test", PluginEnabled: true, + } + if err := st.UpsertProviderConfig(ctx, repositoryProvider); err != nil { + t.Fatal(err) + } + repositoryInstallation := &domain.PluginInstallation{ + ID: "receipt-gitea", ProjectID: project.ID, Provider: domain.PluginGitea, + Status: domain.PluginStatusEnabled, AccessTokenEnc: []byte("sealed-repository-token"), + ConfigRevision: repositoryProvider.ConfigRevision, + } + if err := st.CreatePluginInstallation(ctx, repositoryInstallation); err != nil { + t.Fatal(err) + } + if err := st.UpsertServiceRepositoryBinding(ctx, &domain.ServiceRepositoryBinding{ + ServiceID: service.ID, InstallationID: repositoryInstallation.ID, + ProviderRepoID: "42", RepositoryPath: service.RepoOwnerName, + CloneURL: "https://gitea.test/acme/payments.git", DefaultBranch: "main", + }); err != nil { + t.Fatal(err) + } + provider := &domain.ProviderConfig{ + Provider: domain.PluginJType, BaseURL: "https://jtype.test", PluginEnabled: true, + } + if err := st.UpsertProviderConfig(ctx, provider); err != nil { + t.Fatal(err) + } + installation := &domain.PluginInstallation{ + ID: "receipt-jtype", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "workspace", + AccessTokenEnc: []byte("sealed"), ConfigRevision: provider.ConfigRevision, + } + if err := st.CreatePluginInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + automation := &domain.PluginAutomation{ + ID: "receipt-automation", ServiceID: service.ID, InstallationID: installation.ID, + Name: "Kanban", TriggerKind: "kanban", RunKind: domain.RunKindAgent, Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "delivery", TriggerColumn: "agent", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + observed, err := st.ObservePluginKanbanCard(ctx, store.PluginKanbanObservation{ + AutomationID: automation.ID, ServiceID: service.ID, InstallationID: installation.ID, + WorkspaceID: "workspace", DocumentID: "card", DocumentPath: "cards/payment.md", + TriggerColumn: "agent", DoneColumn: "done", ObservedColumn: "agent", + EventKey: "event:1", EventSequence: int64PtrAPI(1), ActorDisplay: "jtype editor", + ObservedAt: time.Now().UTC(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetPluginKanbanOccurrenceBlocked( + ctx, observed.Occurrence.ID, "model_not_configured", + "Choose an allowed model for this Service.", "project_owner", + ); err != nil { + t.Fatal(err) + } + firstRun := &domain.Run{ + ID: "receipt-run", ProjectID: project.ID, ServiceID: service.ID, + Status: domain.StatusQueued, Origin: domain.RunOriginKanban, + OriginAutomationID: automation.ID, OriginEventKey: observed.Occurrence.ID, + CreatedAt: time.Now().UTC().Add(-time.Minute), + } + if attached, err := st.CreatePluginKanbanOccurrenceRun(ctx, observed.Occurrence.ID, firstRun); err != nil || !attached { + t.Fatalf("attach first execution=%v err=%v", attached, err) + } + if _, err := st.ScheduleRun(ctx, firstRun.ID, "job", "token", "Scheduling"); err != nil { + t.Fatal(err) + } + if _, err := st.MarkRunning(ctx, firstRun.ID, "Running", time.Now().UTC()); err != nil { + t.Fatal(err) + } + if _, err := st.MarkSucceeded(ctx, firstRun.ID, "Succeeded", time.Now().UTC()); err != nil { + t.Fatal(err) + } + if wrote, err := st.MarkPluginKanbanWriteback( + ctx, automation.ID, "card", observed.Occurrence.ID, + domain.StatusSucceeded, nil, time.Now().UTC(), + ); err != nil || !wrote { + t.Fatalf("writeback=%v err=%v", wrote, err) + } + now := time.Now().UTC() + if _, err := st.ObservePluginKanbanCard(ctx, store.PluginKanbanObservation{ + AutomationID: automation.ID, ServiceID: service.ID, InstallationID: installation.ID, + WorkspaceID: "workspace", DocumentID: "card", DocumentPath: "cards/payment.md", + TriggerColumn: "agent", DoneColumn: "done", ObservedColumn: "todo", + EventKey: "event:2", EventSequence: int64PtrAPI(2), ObservedAt: now, + }); err != nil { + t.Fatal(err) + } + second, err := st.ObservePluginKanbanCard(ctx, store.PluginKanbanObservation{ + AutomationID: automation.ID, ServiceID: service.ID, InstallationID: installation.ID, + WorkspaceID: "workspace", DocumentID: "card", DocumentPath: "cards/payment.md", + TriggerColumn: "agent", DoneColumn: "done", ObservedColumn: "agent", + EventKey: "event:3", EventSequence: int64PtrAPI(3), ActorDisplay: "jtype editor", + ObservedAt: now.Add(time.Second), + }) + if err != nil || second.Occurrence == nil { + t.Fatalf("second execution=%+v err=%v", second, err) + } + if _, err := st.SetPluginKanbanOccurrenceBlocked( + ctx, second.Occurrence.ID, "model_not_configured", + "Choose an allowed model for this Service.", "project_owner", + ); err != nil { + t.Fatal(err) + } + + resp := do(t, http.MethodGet, ts.URL+"/api/v1/services/"+service.ID+"/kanban/policy", consoleToken, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("policy status=%d", resp.StatusCode) + } + var policy map[string]any + decode(t, resp, &policy) + if policy["service_name"] != "payments-api" || policy["repository"] != "acme/payments" || + policy["trigger_column"].(map[string]any)["key"] != "agent" { + t.Fatalf("policy=%+v", policy) + } + health := policy["health"].(map[string]any) + if health["state"] != "blocked" || health["blocker"] != "model_not_configured" { + t.Fatalf("health=%+v", health) + } + if health["repair_role"] != "project_owner" { + t.Fatalf("health repair role=%+v", health) + } + + spec, err := st.GetPluginAutomationSpec(ctx, automation.ID) + if err != nil { + t.Fatal(err) + } + spec.Automation.LastError = "board_drift: the configured board or column no longer exists" + if err := st.UpdatePluginAutomation(ctx, &spec.Automation); err != nil { + t.Fatal(err) + } + resp = do(t, http.MethodGet, ts.URL+"/api/v1/services/"+service.ID+"/kanban/policy", consoleToken, nil) + var driftPolicy map[string]any + decode(t, resp, &driftPolicy) + driftHealth := driftPolicy["health"].(map[string]any) + if driftHealth["state"] != "blocked" || driftHealth["blocker"] != "board_drift" { + t.Fatalf("drift health=%+v", driftHealth) + } + + resp = do(t, http.MethodGet, ts.URL+"/api/v1/services/"+service.ID+ + "/kanban/card-executions?workspace_id=workspace&document_path=cards%2Fpayment.md&limit=1", + consoleToken, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("executions status=%d", resp.StatusCode) + } + var executions struct { + Claim struct { + DocumentPath string `json:"document_path"` + ExternalRefAvailable bool `json:"external_ref_available"` + } `json:"claim"` + Items []map[string]any `json:"items"` + NextCursor *string `json:"next_cursor"` + } + decode(t, resp, &executions) + if executions.Claim.DocumentPath != "cards/payment.md" || !executions.Claim.ExternalRefAvailable { + t.Fatalf("claim=%+v", executions.Claim) + } + if len(executions.Items) != 1 || executions.NextCursor == nil || + executions.Items[0]["status"] != "blocked" || + executions.Items[0]["reason_code"] != "model_not_configured" || + executions.Items[0]["requested_actor"].(map[string]any)["label"] != "jtype editor" { + t.Fatalf("executions=%+v", executions.Items) + } + resp = do(t, http.MethodGet, ts.URL+"/api/v1/services/"+service.ID+ + "/kanban/card-executions?workspace_id=workspace&document_path=cards%2Fpayment.md&limit=1&before="+ + *executions.NextCursor, consoleToken, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("second executions page status=%d", resp.StatusCode) + } + var earlier struct { + Items []map[string]any `json:"items"` + NextCursor *string `json:"next_cursor"` + } + decode(t, resp, &earlier) + if len(earlier.Items) != 1 || earlier.Items[0]["status"] != "terminal" || + earlier.Items[0]["outcome"] != "succeeded" || earlier.NextCursor != nil { + t.Fatalf("earlier executions=%+v cursor=%v", earlier.Items, earlier.NextCursor) + } + + resp = do(t, http.MethodGet, ts.URL+"/api/v1/services/"+service.ID+ + "/kanban/card-executions?workspace_id=workspace&document_path=cards%2Fpayment.md&before=not-a-cursor", + consoleToken, nil) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("invalid cursor status=%d want 400", resp.StatusCode) + } + resp.Body.Close() + + resp = do(t, http.MethodGet, ts.URL+"/api/v1/services/"+service.ID+ + "/kanban/card-executions?workspace_id=other&document_path=cards%2Fpayment.md", + consoleToken, nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("cross-workspace status=%d want 404", resp.StatusCode) + } + resp.Body.Close() +} + +func int64PtrAPI(value int64) *int64 { return &value } + +func TestServiceKanbanExecutionMissingRunIsVisible(t *testing.T) { + view := serviceKanbanExecutionView(domain.PluginKanbanOccurrence{ + ID: "occurrence", RunID: "deleted-run", + State: domain.KanbanOccurrenceQueued, + }, nil) + if view.Status != domain.KanbanOccurrenceBlocked || + view.ReasonCode != "run_unavailable" || + view.Reason == nil || + view.RepairRole == nil || *view.RepairRole != "project_owner" { + t.Fatalf("missing Run view=%+v", view) + } +} + func TestServiceKanbanUsesDefaultTriggerAndStaysOutOfAutomations(t *testing.T) { ts, st, srv := newServiceKanbanServer(t) validatorCalls := 0 srv.boardValidatorFor = func(*jtype.Factory, string) boardValidator { return serviceKanbanBoardValidator{ board: &jtype.Board{ - ID: "b_board", - Columns: []jtype.BoardColumn{{Key: "ai"}, {Key: "review"}, {Key: "done"}}, + ID: "b_board", + Columns: []jtype.BoardColumn{ + {Key: "ai", Name: "Agent queue"}, + {Key: "review", Name: "Human review"}, + {Key: "done", Name: "Done"}, + }, }, calls: &validatorCalls, } @@ -122,7 +364,10 @@ func TestServiceKanbanUsesDefaultTriggerAndStaysOutOfAutomations(t *testing.T) { } var created domain.PluginAutomationSpec decode(t, resp, &created) - if created.Automation.TriggerKind != "kanban" || created.Kanban == nil || created.Kanban.BoardRef != "b_board" || created.Kanban.TriggerColumn != "ai" || created.Kanban.DoneColumn != "done" { + if created.Automation.TriggerKind != "kanban" || created.Kanban == nil || + created.Kanban.BoardRef != "b_board" || created.Kanban.TriggerColumn != "ai" || + created.Kanban.TriggerLabel != "Agent queue" || + created.Kanban.DoneColumn != "done" || created.Kanban.DoneLabel != "Done" { t.Fatalf("binding=%+v", created) } if validatorCalls != 1 { @@ -156,7 +401,8 @@ func TestServiceKanbanUsesDefaultTriggerAndStaysOutOfAutomations(t *testing.T) { } var updated domain.PluginAutomationSpec decode(t, resp, &updated) - if updated.Kanban == nil || updated.Kanban.TriggerColumn != "review" || updated.Kanban.DoneColumn != "done" { + if updated.Kanban == nil || updated.Kanban.TriggerColumn != "review" || + updated.Kanban.TriggerLabel != "Human review" || updated.Kanban.DoneColumn != "done" { t.Fatalf("updated columns=%+v", updated.Kanban) } if validatorCalls != 2 { diff --git a/orchestrator/internal/domain/plugins.go b/orchestrator/internal/domain/plugins.go index cffe9e8e..bc4e484b 100644 --- a/orchestrator/internal/domain/plugins.go +++ b/orchestrator/internal/domain/plugins.go @@ -191,11 +191,15 @@ type SCMAction struct { } type KanbanTrigger struct { - AutomationID string `json:"automation_id"` - InstallationID string `json:"installation_id"` - BoardRef string `json:"board_ref"` - TriggerColumn string `json:"trigger_column"` - DoneColumn string `json:"done_column,omitempty"` + AutomationID string `json:"automation_id"` + InstallationID string `json:"installation_id"` + BoardRef string `json:"board_ref"` + TriggerColumn string `json:"trigger_column"` + TriggerLabel string `json:"trigger_label,omitempty"` + DoneColumn string `json:"done_column,omitempty"` + DoneLabel string `json:"done_label,omitempty"` + EventCursor int64 `json:"event_cursor"` + BootstrappedAt *time.Time `json:"bootstrapped_at,omitempty"` } type CronTrigger struct { @@ -318,13 +322,56 @@ type PluginAuditEvent struct { } type PluginKanbanClaim struct { - AutomationID string `json:"automation_id"` - InstallationID string `json:"installation_id"` - DocumentID string `json:"document_id"` - DocumentPath string `json:"document_path"` - WorkspaceID string `json:"workspace_id"` - DoneColumn string `json:"done_column,omitempty"` - RunID string `json:"run_id,omitempty"` - WritebackAt *time.Time `json:"writeback_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + AutomationID string `json:"automation_id"` + InstallationID string `json:"installation_id"` + DocumentID string `json:"document_id"` + DocumentPath string `json:"document_path"` + WorkspaceID string `json:"workspace_id"` + DoneColumn string `json:"done_column,omitempty"` + RunID string `json:"run_id,omitempty"` + WritebackAt *time.Time `json:"writeback_at,omitempty"` + LastObservedColumn string `json:"last_observed_column,omitempty"` + OutsideTriggerAt *time.Time `json:"outside_trigger_at,omitempty"` + LatestOccurrenceID string `json:"latest_occurrence_id,omitempty"` + ExternalRefAvailable bool `json:"external_ref_available"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type KanbanOccurrenceState string + +const ( + KanbanOccurrenceReceived KanbanOccurrenceState = "received" + KanbanOccurrenceBlocked KanbanOccurrenceState = "blocked" + KanbanOccurrenceQueued KanbanOccurrenceState = "queued" + KanbanOccurrenceRunning KanbanOccurrenceState = "running" + KanbanOccurrenceTerminal KanbanOccurrenceState = "terminal" +) + +type PluginKanbanOccurrence struct { + ID string `json:"id"` + AutomationID string `json:"automation_id"` + ServiceID string `json:"service_id"` + InstallationID string `json:"installation_id"` + WorkspaceID string `json:"workspace_id"` + DocumentID string `json:"document_id"` + DocumentPath string `json:"document_path"` + DoneColumn string `json:"done_column,omitempty"` + EventKey string `json:"event_key"` + EventSequence *int64 `json:"event_sequence,omitempty"` + ActorDisplay string `json:"actor_display,omitempty"` + EntryColumn string `json:"entry_column"` + State KanbanOccurrenceState `json:"state"` + Outcome string `json:"outcome,omitempty"` + ReasonCode string `json:"reason_code,omitempty"` + ReasonMessage string `json:"reason_message,omitempty"` + RepairRole string `json:"repair_role,omitempty"` + RunID string `json:"run_id,omitempty"` + ReceiptPhase string `json:"receipt_phase,omitempty"` + ReceiptWrittenAt *time.Time `json:"receipt_written_at,omitempty"` + WritebackState string `json:"writeback_state"` + WritebackError string `json:"writeback_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TerminalAt *time.Time `json:"terminal_at,omitempty"` } diff --git a/orchestrator/internal/jtype/board_resolve_test.go b/orchestrator/internal/jtype/board_resolve_test.go index 321d3e8a..ac155bda 100644 --- a/orchestrator/internal/jtype/board_resolve_test.go +++ b/orchestrator/internal/jtype/board_resolve_test.go @@ -3,9 +3,11 @@ package jtype import ( "context" "errors" + "io" "net/http" "net/http/httptest" "testing" + "time" ) // docsFrom builds a []Doc from relativePaths (ids are "id:"+path). @@ -124,6 +126,28 @@ func TestGetBoard_ReturnsConfigID(t *testing.T) { } } +func TestGetBoardByConfigIDFindsRenamedBoardDocument(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/workspaces/ws/documents", func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `[{"id":"board-doc","relativePath":"renamed/delivery.board","title":"Delivery"}]`) + }) + mux.HandleFunc("/api/v1/workspaces/ws/documents/board-doc", func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"relativePath":"renamed/delivery.board","title":"Delivery","content":"{\"id\":\"b_stable\",\"title\":\"Delivery\",\"columns\":[{\"key\":\"agent\",\"name\":\"Agent queue\"},{\"key\":\"done\",\"name\":\"Done\"}]}","contentHash":"h","updatedClock":2}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + board, err := NewClient(server.URL, "token", time.Second).GetBoardByConfigID( + context.Background(), "ws", "b_stable", + ) + if err != nil { + t.Fatal(err) + } + if board.ID != "b_stable" || len(board.Columns) != 2 || board.Columns[0].Name != "Agent queue" { + t.Fatalf("board=%+v", board) + } +} + // C4 client-level: ListWorkspaces hits GET /api/v1/workspaces (which jtype wraps // as {"workspaces":[…]}, NOT a bare array) and tolerates either a name or title // label. diff --git a/orchestrator/internal/jtype/client.go b/orchestrator/internal/jtype/client.go index ea4d7bca..dd4af93d 100644 --- a/orchestrator/internal/jtype/client.go +++ b/orchestrator/internal/jtype/client.go @@ -101,6 +101,15 @@ type KanbanEventPage struct { HasMore bool `json:"hasMore"` } +type Comment struct { + ID string `json:"id"` + Body string `json:"body"` +} + +func KanbanReceiptMarker(occurrenceID, phase string) string { + return "" +} + // ListDocuments returns every document in the workspace. The poller uses it for // its one-time compatibility scan and to resolve event paths to document ids. // 4xx/5xx return a typed *Error. @@ -194,6 +203,18 @@ func (c *Client) AddComment(ctx context.Context, workspace, docID, body string) return nil } +// ListComments returns the stable ids and bodies needed to make Cloud receipt +// projection idempotent. Other comment metadata remains owned by jtype. +func (c *Client) ListComments(ctx context.Context, workspace, docID string) ([]Comment, error) { + var comments []Comment + if err := c.getJSON(ctx, + c.path("/api/v1/workspaces/%s/documents/%s/comments", workspace, docID), + &comments); err != nil { + return nil, err + } + return comments, nil +} + // MoveCard reads a card, rewrites its frontmatter status to newStatus, and // saves it (a column move in jtype). It passes the fetched content hash so a // concurrent edit surfaces as a retryable 409 rather than clobbering the card. @@ -342,6 +363,33 @@ func (c *Client) GetBoardByDoc(ctx context.Context, workspace, docID string) (*B return b, nil } +// GetBoardByConfigID resolves the immutable board id stored in Card +// frontmatter and Kanban Automation bindings. Unlike GetBoard, which accepts a +// user-facing document path/name, this survives a .board document rename. +func (c *Client) GetBoardByConfigID(ctx context.Context, workspace, configID string) (*Board, error) { + configID = strings.TrimSpace(configID) + if configID == "" { + return nil, ErrDocNotFound + } + docs, err := c.ListDocuments(ctx, workspace) + if err != nil { + return nil, err + } + for _, doc := range docs { + if !strings.HasSuffix(strings.ToLower(doc.Path), ".board") { + continue + } + board, boardErr := c.GetBoardByDoc(ctx, workspace, doc.ID) + if boardErr != nil { + return nil, boardErr + } + if board.ID == configID { + return board, nil + } + } + return nil, ErrDocNotFound +} + // Workspace is a caller-visible jtype workspace (the fields the console picker // needs). The token is never part of this shape. type Workspace struct { diff --git a/orchestrator/internal/jtype/client_test.go b/orchestrator/internal/jtype/client_test.go index 081735b1..156367ac 100644 --- a/orchestrator/internal/jtype/client_test.go +++ b/orchestrator/internal/jtype/client_test.go @@ -97,7 +97,9 @@ func (f *fakeJtype) handle(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"id": "cmt-1"}) case strings.Contains(suffix, "/comments") && r.Method == http.MethodGet: - writeJSON(w, http.StatusOK, []any{}) + writeJSON(w, http.StatusOK, []any{ + map[string]any{"id": "cmt-1", "body": f.lastBody}, + }) default: // GET .../documents/{id} @@ -115,6 +117,23 @@ func (f *fakeJtype) handle(w http.ResponseWriter, r *http.Request) { } } +func TestClientListsCardCommentBodies(t *testing.T) { + f := newFakeJtype() + srv := httptest.NewServer(f.mux) + defer srv.Close() + c := NewClient(srv.URL, "token", 0) + if err := c.AddComment(context.Background(), "ws", "doc", " accepted"); err != nil { + t.Fatal(err) + } + comments, err := c.ListComments(context.Background(), "ws", "doc") + if err != nil { + t.Fatal(err) + } + if len(comments) != 1 || comments[0].ID != "cmt-1" || comments[0].Body != " accepted" { + t.Fatalf("comments = %+v", comments) + } +} + func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) diff --git a/orchestrator/internal/kanban/poller.go b/orchestrator/internal/kanban/poller.go index 9559f432..d83f2c9e 100644 --- a/orchestrator/internal/kanban/poller.go +++ b/orchestrator/internal/kanban/poller.go @@ -16,7 +16,10 @@ package kanban import ( "context" "errors" + "fmt" "log/slog" + "net/http" + "strconv" "strings" "sync" "time" @@ -34,6 +37,7 @@ type DocumentAPI interface { ListDocuments(ctx context.Context, workspace string) ([]jtype.Doc, error) PullBoardEvents(ctx context.Context, workspace, boardRef string, afterSequence int64, limit int) (*jtype.KanbanEventPage, error) GetDocument(ctx context.Context, workspace, id string) (*jtype.Document, error) + ListComments(ctx context.Context, workspace, docID string) ([]jtype.Comment, error) AddComment(ctx context.Context, workspace, docID, body string) error // GetBoard resolves a board by name/ref and returns its config id + columns. // Used by the runtime fail-visible re-validation of an unvalidated/invalid link @@ -41,6 +45,10 @@ type DocumentAPI interface { GetBoard(ctx context.Context, workspace, boardRef string) (*jtype.Board, error) } +type boardConfigInspector interface { + GetBoardByConfigID(ctx context.Context, workspace, configID string) (*jtype.Board, error) +} + // ModelResolver runs the D21 resolution chain for a project/service so the poller // can fail-visible (skip + comment) instead of queueing a run that could never // execute. requested is always "" on this headless path (no composer pick). @@ -181,13 +189,70 @@ func (p *Poller) pollPluginAutomation(ctx context.Context, factory *jtype.Factor return } api := p.clientFor(factory, token) - docs, err := api.ListDocuments(ctx, installation.WorkspaceID) + // Existing occurrences and their receipts use frozen routing and remain + // retryable even while the live board configuration is drifting. Board + // validation fences only bootstrap/event consumption, i.e. new triggers. + p.retryPluginKanbanOccurrences(ctx, api, spec) + p.retryPluginKanbanReceipts(ctx, api, spec) + if !p.validatePluginKanbanBoard(ctx, api, spec, installation.WorkspaceID) { + return + } + if spec.Kanban.BootstrappedAt == nil { + p.bootstrapPluginAutomation(ctx, api, spec, installation) + return + } + p.consumePluginAutomationEvents(ctx, api, spec, installation) +} + +func (p *Poller) validatePluginKanbanBoard( + ctx context.Context, + api DocumentAPI, + spec *domain.PluginAutomationSpec, + workspaceID string, +) bool { + inspector, ok := api.(boardConfigInspector) + if !ok { + // Focused test doubles can implement only DocumentAPI. Production's + // *jtype.Client always implements the config-id lookup. + return true + } + board, err := inspector.GetBoardByConfigID(ctx, workspaceID, spec.Kanban.BoardRef) + if err != nil { + code := "board_validation_unavailable" + message := "JType could not validate the configured board." + if errors.Is(err, jtype.ErrDocNotFound) { + code = "board_drift" + message = "The configured JType board no longer exists." + } + spec.Automation.LastError = code + ": " + message + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + return false + } + if !boardHasColumn(board, spec.Kanban.TriggerColumn) || + (spec.Kanban.DoneColumn != "" && !boardHasColumn(board, spec.Kanban.DoneColumn)) { + spec.Automation.LastError = "board_drift: A configured Kanban column no longer exists." + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + return false + } + if strings.HasPrefix(spec.Automation.LastError, "board_drift:") || + strings.HasPrefix(spec.Automation.LastError, "board_validation_unavailable:") { + spec.Automation.LastError = "" + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + } + return true +} + +func (p *Poller) bootstrapPluginAutomation(ctx context.Context, api DocumentAPI, spec *domain.PluginAutomationSpec, installation *domain.PluginInstallation) { + head, err := pluginAutomationEventHead(ctx, api, installation.WorkspaceID, spec.Kanban.BoardRef, spec.Kanban.EventCursor) if err != nil { - p.log.Warn("Kanban Automation poll: list documents", "automation", spec.Automation.ID, "err", err) + spec.Automation.LastError = "event_feed_unavailable: JType board events could not be read." + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) return } - svc, err := p.st.GetService(ctx, spec.Automation.ServiceID) + docs, err := api.ListDocuments(ctx, installation.WorkspaceID) if err != nil { + spec.Automation.LastError = "bootstrap_unavailable: JType cards could not be listed." + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) return } for _, doc := range docs { @@ -196,52 +261,404 @@ func (p *Poller) pollPluginAutomation(ctx context.Context, factory *jtype.Factor } full, err := api.GetDocument(ctx, installation.WorkspaceID, doc.ID) if err != nil { - continue + spec.Automation.LastError = "bootstrap_unavailable: A JType Card could not be read." + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + return } card := jtype.ParseCard(full.Content) - if card.Board != spec.Kanban.BoardRef || card.Status != spec.Kanban.TriggerColumn { + if card.Board != spec.Kanban.BoardRef || strings.TrimSpace(card.Status) == "" { continue } - claim, err := p.st.EnsurePluginKanbanClaim(ctx, spec.Automation.ID, doc.ID, doc.Path, installation.WorkspaceID, spec.Kanban.DoneColumn) - if err != nil || claim.RunID != "" { - continue + result, err := p.st.ObservePluginKanbanCard(ctx, store.PluginKanbanObservation{ + AutomationID: spec.Automation.ID, ServiceID: spec.Automation.ServiceID, + InstallationID: installation.ID, WorkspaceID: installation.WorkspaceID, + DocumentID: doc.ID, DocumentPath: doc.Path, TriggerColumn: spec.Kanban.TriggerColumn, + DoneColumn: spec.Kanban.DoneColumn, ObservedColumn: card.Status, + EventKey: "bootstrap:" + spec.Automation.ID + ":" + doc.ID, ObservedAt: p.now(), + }) + if err != nil { + return + } + if result.Created && result.Occurrence != nil { + p.dispatchPluginKanbanOccurrence(ctx, api, spec, result.Occurrence, &card) + } else { + p.projectPluginKanbanSuppressionReceipt(ctx, api, result) } - sel, outcome, err := p.models.SelectModel(ctx, svc.ProjectID, derefStr(svc.DefaultModelID), spec.Automation.ModelID) - if err != nil || outcome != modelcfg.SelectOK { - spec.Automation.LastError = "Automation model is unavailable." + } + now := p.now() + if advanced, err := p.st.AdvancePluginKanbanTrigger(ctx, spec.Automation.ID, spec.Kanban.EventCursor, head, &now); err != nil || !advanced { + return + } + spec.Kanban.EventCursor = head + spec.Kanban.BootstrappedAt = &now + p.clearPluginKanbanPollingError(ctx, spec) +} + +func pluginAutomationEventHead(ctx context.Context, api DocumentAPI, workspaceID, boardRef string, after int64) (int64, error) { + head := after + for { + page, err := api.PullBoardEvents(ctx, workspaceID, boardRef, head, 100) + if err != nil { + return after, err + } + if page.NextSequence < head { + return after, fmt.Errorf("JType event cursor moved backwards from %d to %d", head, page.NextSequence) + } + head = page.NextSequence + if !page.HasMore { + return head, nil + } + if len(page.Events) == 0 || page.NextSequence == after { + return after, errors.New("JType event feed did not advance") + } + after = head + } +} + +func (p *Poller) consumePluginAutomationEvents(ctx context.Context, api DocumentAPI, spec *domain.PluginAutomationSpec, installation *domain.PluginInstallation) { + cursor := spec.Kanban.EventCursor + for { + page, err := api.PullBoardEvents(ctx, installation.WorkspaceID, spec.Kanban.BoardRef, cursor, 100) + if err != nil { + spec.Automation.LastError = "event_feed_unavailable: JType board events could not be read." _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) - continue + return + } + if len(page.Events) == 0 { + p.clearPluginKanbanPollingError(ctx, spec) + return } - if !sel.SupportsEffort(spec.Automation.ModelEffort) { - spec.Automation.LastError = "The selected Automation model no longer supports reasoning effort." + docs, err := api.ListDocuments(ctx, installation.WorkspaceID) + if err != nil { + spec.Automation.LastError = "card_index_unavailable: JType Card index could not be read." _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) - continue + return } - now := p.now() - run := &domain.Run{ - ID: domain.NewID(), ProjectID: svc.ProjectID, ServiceID: svc.ID, - Prompt: buildPrompt(card), Status: domain.StatusQueued, Kind: domain.RunKindAgent, - Phase: "Queued", Origin: domain.RunOriginKanban, - OriginAutomationID: spec.Automation.ID, - OriginEventKey: "kanban:" + spec.Automation.ID + ":" + doc.ID, - Attempt: 1, CreatedAt: now, ModelName: sel.ModelName, - ModelEffort: spec.Automation.ModelEffort, - } - if sel.ModelID != "" { - modelID := sel.ModelID - run.ModelID = &modelID - } - if err := p.st.CreateRun(ctx, run); err != nil { - continue + docsByPath := make(map[string]jtype.Doc, len(docs)) + for _, doc := range docs { + docsByPath[doc.Path] = doc } - if err := p.st.SetPluginKanbanClaimRun(ctx, spec.Automation.ID, doc.ID, run.ID); err != nil { - continue + for i := range page.Events { + event := page.Events[i] + if event.Sequence <= cursor { + continue + } + doc, ok := docsByPath[event.Card.Path] + if !ok { + if _, markErr := p.st.MarkPluginKanbanCardUnavailable( + ctx, spec.Automation.ID, installation.WorkspaceID, event.Card.Path, p.now(), + ); markErr != nil { + return + } + } else if isMarkdown(doc.Path) { + sequence := event.Sequence + result, observeErr := p.st.ObservePluginKanbanCard(ctx, store.PluginKanbanObservation{ + AutomationID: spec.Automation.ID, ServiceID: spec.Automation.ServiceID, + InstallationID: installation.ID, WorkspaceID: installation.WorkspaceID, + DocumentID: doc.ID, DocumentPath: doc.Path, TriggerColumn: spec.Kanban.TriggerColumn, + DoneColumn: spec.Kanban.DoneColumn, ObservedColumn: event.Card.Status, + EventKey: "event:" + strconv.FormatInt(event.Sequence, 10), EventSequence: &sequence, + ActorDisplay: event.EditedBy, ObservedAt: p.now(), + }) + if observeErr != nil { + return + } + if result.Created && result.Occurrence != nil { + p.dispatchPluginKanbanOccurrence(ctx, api, spec, result.Occurrence, nil) + } else { + p.projectPluginKanbanSuppressionReceipt(ctx, api, result) + } + } + advanced, advanceErr := p.st.AdvancePluginKanbanTrigger(ctx, spec.Automation.ID, cursor, event.Sequence, nil) + if advanceErr != nil || !advanced { + return + } + cursor = event.Sequence } - spec.Automation.LastTriggeredAt = &now - spec.Automation.LastRunID = run.ID - spec.Automation.LastError = "" + if !page.HasMore { + p.clearPluginKanbanPollingError(ctx, spec) + return + } + } +} + +func (p *Poller) clearPluginKanbanPollingError(ctx context.Context, spec *domain.PluginAutomationSpec) { + if spec == nil { + return + } + for _, prefix := range []string{ + "event_feed_unavailable:", + "bootstrap_unavailable:", + "card_index_unavailable:", + } { + if strings.HasPrefix(spec.Automation.LastError, prefix) { + spec.Automation.LastError = "" + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + return + } + } +} + +func (p *Poller) dispatchPluginKanbanOccurrence(ctx context.Context, api DocumentAPI, spec *domain.PluginAutomationSpec, occurrence *domain.PluginKanbanOccurrence, parsedCard *jtype.Card) { + if occurrence == nil || occurrence.RunID != "" { + return + } + svc, err := p.st.GetService(ctx, spec.Automation.ServiceID) + if err != nil { + return + } + if reasonCode, reasonMessage, repairRole := p.pluginKanbanRepositoryBlocker(ctx, svc); reasonCode != "" { + p.blockPluginKanbanOccurrence( + ctx, api, spec, occurrence, svc, reasonCode, reasonMessage, repairRole, + ) + return + } + card := parsedCard + if card == nil { + full, err := api.GetDocument(ctx, occurrence.WorkspaceID, occurrence.DocumentID) + if err != nil { + reasonCode := "card_read_unavailable" + reasonMessage := "JType could not read the source Card. Cloud will retry this occurrence." + repairRole := "project_owner" + var jtypeErr *jtype.Error + if errors.As(err, &jtypeErr) && jtypeErr.StatusCode == http.StatusNotFound { + reasonCode = "card_unavailable" + reasonMessage = "The source Card is no longer available in JType." + _, _ = p.st.MarkPluginKanbanCardUnavailable( + ctx, occurrence.AutomationID, occurrence.WorkspaceID, + occurrence.DocumentPath, p.now(), + ) + } + p.blockPluginKanbanOccurrence( + ctx, api, spec, occurrence, svc, reasonCode, reasonMessage, repairRole, + ) + return + } + value := jtype.ParseCard(full.Content) + card = &value + } + sel, outcome, err := p.models.SelectModel(ctx, svc.ProjectID, derefStr(svc.DefaultModelID), spec.Automation.ModelID) + if err != nil || outcome != modelcfg.SelectOK { + spec.Automation.LastError = "Automation model is unavailable." + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + p.blockPluginKanbanOccurrence( + ctx, api, spec, occurrence, svc, "model_not_configured", + "Choose an allowed model for this Service.", "project_owner", + ) + return + } + if !sel.SupportsEffort(spec.Automation.ModelEffort) { + spec.Automation.LastError = "The selected Automation model no longer supports reasoning effort." _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + p.blockPluginKanbanOccurrence( + ctx, api, spec, occurrence, svc, "model_effort_unsupported", + "Choose a supported reasoning effort for this Automation.", "project_owner", + ) + return + } + now := p.now() + run := &domain.Run{ + ID: domain.NewID(), ProjectID: svc.ProjectID, ServiceID: svc.ID, + Prompt: buildPrompt(*card), Status: domain.StatusQueued, Kind: domain.RunKindAgent, + Phase: "Queued", Origin: domain.RunOriginKanban, + OriginAutomationID: spec.Automation.ID, OriginEventKey: occurrence.ID, + Attempt: 1, CreatedAt: now, ModelName: sel.ModelName, + ModelEffort: spec.Automation.ModelEffort, + } + if sel.ModelID != "" { + modelID := sel.ModelID + run.ModelID = &modelID + } + attached, err := p.st.CreatePluginKanbanOccurrenceRun(ctx, occurrence.ID, run) + if err != nil || !attached { + return + } + spec.Automation.LastTriggeredAt = &now + spec.Automation.LastRunID = run.ID + spec.Automation.LastError = "" + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + occurrence.RunID = run.ID + occurrence.State = domain.KanbanOccurrenceQueued + occurrence.ReceiptPhase = "accepted" + occurrence.ReceiptWrittenAt = nil + p.projectPluginKanbanReceipt(ctx, api, occurrence, run, svc) +} + +func (p *Poller) blockPluginKanbanOccurrence( + ctx context.Context, + api DocumentAPI, + spec *domain.PluginAutomationSpec, + occurrence *domain.PluginKanbanOccurrence, + svc *domain.Service, + reasonCode, reasonMessage, repairRole string, +) { + spec.Automation.LastError = reasonCode + ": " + reasonMessage + _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + blocked, err := p.st.SetPluginKanbanOccurrenceBlocked( + ctx, occurrence.ID, reasonCode, reasonMessage, repairRole, + ) + if err == nil { + p.projectPluginKanbanReceipt(ctx, api, blocked, nil, svc) + } +} + +// pluginKanbanRepositoryBlocker checks only durable prerequisites that must be +// true before a Run is created. Temporary scheduler capacity is intentionally +// not a blocker: a valid Run may remain queued until capacity becomes free. +func (p *Poller) pluginKanbanRepositoryBlocker(ctx context.Context, svc *domain.Service) (string, string, string) { + if svc == nil || svc.DeletingAt != nil { + return "service_unavailable", "The target Service is being deleted.", "project_owner" + } + switch svc.RepoKind { + case domain.RepoKindRaw: + if strings.TrimSpace(svc.RawRepoURL) == "" { + return "repository_not_configured", "Configure a repository for this Service.", "project_owner" + } + return "", "", "" + case domain.RepoKindProvider: + if !domain.ValidProvider(svc.Provider) || strings.TrimSpace(svc.RepoOwnerName) == "" { + return "repository_not_configured", "Choose a valid provider repository for this Service.", "project_owner" + } + default: + return "repository_not_configured", "Configure a repository for this Service.", "project_owner" + } + + binding, err := p.st.GetServiceRepositoryBinding(ctx, svc.ID) + if err != nil || binding.InstallationID == "" || strings.TrimSpace(binding.CloneURL) == "" { + return "repository_unavailable", "Reconnect the Service repository Plugin.", "project_owner" + } + installation, err := p.st.GetPluginInstallation(ctx, binding.InstallationID) + if err != nil || installation.Provider != domain.ProviderKind(svc.Provider) || + installation.Status != domain.PluginStatusEnabled || installation.LastHealthError != "" { + return "provider_unavailable", "Repair the repository Provider connection.", "project_owner" + } + if (installation.Provider == domain.PluginGitHub && installation.GitHubInstallID == "") || + (installation.Provider != domain.PluginGitHub && !installation.TokenSet()) { + return "provider_unavailable", "Reconnect the repository Provider credential.", "project_owner" + } + cfg, err := p.st.GetProviderConfig(ctx, installation.Provider) + if err != nil || !cfg.PluginEnabled || strings.TrimSpace(cfg.BaseURL) == "" || + cfg.ConfigRevision != installation.ConfigRevision { + return "provider_unavailable", "Ask a cluster administrator to repair the repository Provider.", "cluster_admin" + } + return "", "", "" +} + +func (p *Poller) retryPluginKanbanOccurrences(ctx context.Context, api DocumentAPI, spec *domain.PluginAutomationSpec) { + pending, err := p.st.ListPluginKanbanDispatchableOccurrences(ctx, spec.Automation.ID, 50) + if err != nil { + return + } + for i := range pending { + p.dispatchPluginKanbanOccurrence(ctx, api, spec, &pending[i], nil) + } +} + +func (p *Poller) retryPluginKanbanReceipts(ctx context.Context, api DocumentAPI, spec *domain.PluginAutomationSpec) { + pending, err := p.st.ListPluginKanbanReceiptPending(ctx, spec.Automation.ID, 50) + if err != nil { + return + } + svc, _ := p.st.GetService(ctx, spec.Automation.ServiceID) + for i := range pending { + occurrence := &pending[i] + var run *domain.Run + if occurrence.RunID != "" { + run, _ = p.st.GetRun(ctx, occurrence.RunID) + } + p.projectPluginKanbanReceipt(ctx, api, occurrence, run, svc) + } +} + +func (p *Poller) projectPluginKanbanSuppressionReceipt( + ctx context.Context, + api DocumentAPI, + result *store.PluginKanbanObservationResult, +) { + if result == nil || result.Occurrence == nil || + (result.SuppressedReason != store.PluginKanbanAlreadyRunning && + result.SuppressedReason != store.PluginKanbanWritebackPending) { + return + } + occurrence, err := p.st.SetPluginKanbanOccurrenceReceiptPhase( + ctx, result.Occurrence.ID, result.SuppressedReason, + ) + if err != nil { + return + } + var run *domain.Run + if occurrence.RunID != "" { + run, _ = p.st.GetRun(ctx, occurrence.RunID) + } + p.projectPluginKanbanReceipt(ctx, api, occurrence, run, nil) +} + +func (p *Poller) projectPluginKanbanReceipt(ctx context.Context, api DocumentAPI, occurrence *domain.PluginKanbanOccurrence, run *domain.Run, svc *domain.Service) { + if occurrence == nil || occurrence.ReceiptPhase == "" || occurrence.ReceiptWrittenAt != nil { + return + } + marker := jtype.KanbanReceiptMarker(occurrence.ID, occurrence.ReceiptPhase) + dedupeMarker := marker + if occurrence.ReceiptPhase == "blocked" && occurrence.ReasonCode != "" { + dedupeMarker = jtype.KanbanReceiptMarker( + occurrence.ID, + "blocked-"+occurrence.ReasonCode+"-"+occurrence.RepairRole, + ) + } + comments, err := api.ListComments(ctx, occurrence.WorkspaceID, occurrence.DocumentID) + if err != nil { + _ = p.st.MarkPluginKanbanOccurrenceReceipt(ctx, occurrence.ID, occurrence.ReceiptPhase, nil, "JType comments could not be read.") + return + } + for _, comment := range comments { + if strings.Contains(comment.Body, dedupeMarker) { + now := p.now() + _ = p.st.MarkPluginKanbanOccurrenceReceipt(ctx, occurrence.ID, occurrence.ReceiptPhase, &now, "") + return + } + } + + body := marker + "\n" + if dedupeMarker != marker { + body += dedupeMarker + "\n" + } + switch occurrence.ReceiptPhase { + case "blocked": + body += "jcode Cloud could not start this Card: " + occurrence.ReasonMessage + if occurrence.RepairRole == "project_owner" { + body += "\n\nNext: ask a Project owner to update the Automation or Service configuration." + } else if occurrence.RepairRole == "cluster_admin" { + body += "\n\nNext: ask a cluster administrator to repair the required Provider." + } + case "accepted": + if run == nil || svc == nil { + return + } + model := run.ModelName + if model == "" { + model = "the configured model" + } + body += "jcode Cloud accepted this Card for `" + svc.Name + "` using `" + model + "`." + body += "\n\nRun: " + strings.TrimRight(p.consoleURL, "/") + "/runs/" + run.ID + case store.PluginKanbanAlreadyRunning: + body += "jcode Cloud is already working on this Card." + if run != nil { + body += "\n\nRun: " + strings.TrimRight(p.consoleURL, "/") + "/runs/" + run.ID + } + case store.PluginKanbanWritebackPending: + body += "The Run has finished; jcode Cloud is still syncing its result to this Card." + if run != nil { + body += "\n\nRun: " + strings.TrimRight(p.consoleURL, "/") + "/runs/" + run.ID + } + default: + return + } + if err := api.AddComment(ctx, occurrence.WorkspaceID, occurrence.DocumentID, body); err != nil { + _ = p.st.MarkPluginKanbanOccurrenceReceipt(ctx, occurrence.ID, occurrence.ReceiptPhase, nil, "JType receipt comment could not be written.") + return } + now := p.now() + _ = p.st.MarkPluginKanbanOccurrenceReceipt(ctx, occurrence.ID, occurrence.ReceiptPhase, &now, "") } // pollLink pulls one link's durable board-event sequence. Errors reaching jtype diff --git a/orchestrator/internal/kanban/poller_test.go b/orchestrator/internal/kanban/poller_test.go index 7bd98ed3..7af462c4 100644 --- a/orchestrator/internal/kanban/poller_test.go +++ b/orchestrator/internal/kanban/poller_test.go @@ -2,8 +2,11 @@ package kanban import ( "context" + "errors" "io" "log/slog" + "strconv" + "strings" "sync" "testing" "time" @@ -35,22 +38,24 @@ func testLogger(t *testing.T) *slog.Logger { // fakeAPI is an in-memory jtype stand-in for poller tests. type fakeAPI struct { - mu sync.Mutex - docs map[string]jtype.Doc // id -> list item - contents map[string]string // id -> content - events []jtype.KanbanEvent - comments map[string][]string // docID -> bodies - boards map[string]*jtype.Board // ref -> resolved board (D30 runtime check) - boardErr error // when set, GetBoard returns it (transient/definitive) - getCalls int // number of GetDocument calls (cursor probe) - listCalls int - boardCalls int // number of GetBoard calls (revalidation probe) - pullCalls []int64 - pageSize int - listErr error - pullErr error - getErrOnce map[string]error - tokens []string // PATs the token->client factory was asked to bind (F6) + mu sync.Mutex + docs map[string]jtype.Doc // id -> list item + contents map[string]string // id -> content + events []jtype.KanbanEvent + comments map[string][]string // docID -> bodies + boards map[string]*jtype.Board // ref -> resolved board (D30 runtime check) + boardErr error // when set, GetBoard returns it (transient/definitive) + configBoardMissing bool // GetBoardByConfigID returns a durable drift signal + getCalls int // number of GetDocument calls (cursor probe) + listCalls int + boardCalls int // number of GetBoard calls (revalidation probe) + pullCalls []int64 + pageSize int + listErr error + pullErr error + getErrOnce map[string]error + tokens []string // PATs the token->client factory was asked to bind (F6) + commentPersistThenErrOnce bool } func newFakeAPI() *fakeAPI { @@ -79,6 +84,29 @@ func (f *fakeAPI) GetBoard(ctx context.Context, ws, ref string) (*jtype.Board, e return nil, jtype.ErrDocNotFound } +func (f *fakeAPI) GetBoardByConfigID(ctx context.Context, ws, ref string) (*jtype.Board, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.boardCalls++ + if f.boardErr != nil { + return nil, f.boardErr + } + if f.configBoardMissing { + return nil, jtype.ErrDocNotFound + } + if b, ok := f.boards[ref]; ok { + return b, nil + } + return &jtype.Board{ + ID: ref, + Columns: []jtype.BoardColumn{ + {Key: "ai", Name: "Agent queue"}, + {Key: "agent", Name: "Agent queue"}, + {Key: "done", Name: "Done"}, + }, + }, nil +} + func (f *fakeAPI) addCard(id, path, board, status, title, body string, clock int64) { f.addCardWithoutEvent(id, path, board, status, title, body, clock) f.addEvent(clock, board, path, status, title) @@ -151,9 +179,23 @@ func (f *fakeAPI) AddComment(ctx context.Context, ws, docID, body string) error f.mu.Lock() defer f.mu.Unlock() f.comments[docID] = append(f.comments[docID], body) + if f.commentPersistThenErrOnce { + f.commentPersistThenErrOnce = false + return errors.New("timeout after remote comment success") + } return nil } +func (f *fakeAPI) ListComments(ctx context.Context, ws, docID string) ([]jtype.Comment, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]jtype.Comment, 0, len(f.comments[docID])) + for index, body := range f.comments[docID] { + out = append(out, jtype.Comment{ID: "comment-" + strconv.Itoa(index+1), Body: body}) + } + return out, nil +} + // modelStub returns a fixed selection outcome. When SelectOK it yields a fixed // reasoning-capable model so the poller stamps runs.model_id/model_name/effort. type modelStub struct { @@ -241,7 +283,7 @@ func TestPluginKanbanAutomationDispatchesAndClaimsOnce(t *testing.T) { if err := st.CreateProject(ctx, project); err != nil { t.Fatal(err) } - service := &domain.Service{ID: domain.NewID(), ProjectID: project.ID, Name: "svc", RepoKind: domain.RepoKindProvider, Provider: domain.ProviderGitea, RepoOwnerName: "acme/repo", DefaultBranch: "main", CreatedAt: time.Now()} + service := &domain.Service{ID: domain.NewID(), ProjectID: project.ID, Name: "svc", RepoKind: domain.RepoKindRaw, RawRepoURL: "https://git.test/acme/repo.git", DefaultBranch: "main", CreatedAt: time.Now()} if err := st.CreateService(ctx, service); err != nil { t.Fatal(err) } @@ -264,6 +306,8 @@ func TestPluginKanbanAutomationDispatchesAndClaimsOnce(t *testing.T) { } api := newFakeAPI() api.addCardWithoutEvent("doc-plugin", "cards/plugin.md", "b", "ai", "Fix plugin", "keep constraints", 1) + api.getErrOnce["doc-plugin"] = errors.New("transient Card read") + api.commentPersistThenErrOnce = true clientFor := func(_ *jtype.Factory, token string) DocumentAPI { api.tokens = append(api.tokens, token) return api @@ -272,9 +316,19 @@ func TestPluginKanbanAutomationDispatchesAndClaimsOnce(t *testing.T) { models := modelStub{outcome: modelcfg.SelectOK, requested: &requested} poller := New(st, envResolver(st, "http://legacy-unused"), clientFor, testDecrypt, models, testLogger(t), "http://console", time.Second) poller.Tick(ctx) + spec, err := st.GetPluginAutomationSpec(ctx, automation.ID) + if err != nil || spec.Kanban.BootstrappedAt != nil || + !strings.HasPrefix(spec.Automation.LastError, "bootstrap_unavailable:") { + t.Fatalf("partial bootstrap committed or hid failure: spec=%+v err=%v", spec, err) + } + runs, err := st.ListRunsByService(ctx, service.ID, 10) + if err != nil || len(runs) != 0 { + t.Fatalf("partial bootstrap runs=%d err=%v, want 0", len(runs), err) + } + poller.Tick(ctx) poller.Tick(ctx) - runs, err := st.ListRunsByService(ctx, service.ID, 10) + runs, err = st.ListRunsByService(ctx, service.ID, 10) if err != nil || len(runs) != 1 { t.Fatalf("runs=%d err=%v", len(runs), err) } @@ -289,6 +343,532 @@ func TestPluginKanbanAutomationDispatchesAndClaimsOnce(t *testing.T) { if len(api.tokens) == 0 || api.tokens[0] != "PLAIN-PLUGINPAT" { t.Fatalf("tokens=%v", api.tokens) } + if comments := api.comments["doc-plugin"]; len(comments) != 1 || + !strings.Contains(comments[0], "") { + t.Fatalf("accepted receipt comments=%q", comments) + } + spec, err = st.GetPluginAutomationSpec(ctx, automation.ID) + if err != nil || spec.Kanban == nil || spec.Kanban.BootstrappedAt == nil { + t.Fatalf("trigger was not bootstrapped: %+v, %v", spec, err) + } + if len(api.pullCalls) == 0 { + t.Fatal("bootstrap did not establish a durable event cursor") + } +} + +func TestPluginKanbanAutomationConsumesEventsWithoutLevelRescan(t *testing.T) { + ctx := context.Background() + st := store.NewMemStore() + project := &domain.Project{ID: "plugin-events-project", Name: "plugin-events"} + service := &domain.Service{ID: "plugin-events-service", ProjectID: project.ID, Name: "svc", RepoKind: domain.RepoKindRaw, RawRepoURL: "https://git.test/acme/repo.git"} + if err := st.CreateProject(ctx, project); err != nil { + t.Fatal(err) + } + if err := st.CreateService(ctx, service); err != nil { + t.Fatal(err) + } + cfg := &domain.ProviderConfig{Provider: domain.PluginJType, BaseURL: "http://jtype.plugin", PluginEnabled: true} + if err := st.UpsertProviderConfig(ctx, cfg); err != nil { + t.Fatal(err) + } + installation := &domain.PluginInstallation{ + ID: "plugin-events-installation", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "ws", AccessTokenEnc: []byte("PLUGINPAT"), + ConfigRevision: cfg.ConfigRevision, + } + if err := st.CreatePluginInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + automation := &domain.PluginAutomation{ + ID: "plugin-events-automation", ServiceID: service.ID, InstallationID: installation.ID, + Name: "board", TriggerKind: "kanban", RunKind: domain.RunKindAgent, Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "b", TriggerColumn: "ai", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + api := newFakeAPI() + api.addCardWithoutEvent("doc-plugin", "cards/plugin.md", "b", "ai", "Fix plugin", "keep constraints", 1) + clientFor := func(_ *jtype.Factory, token string) DocumentAPI { return api } + poller := New(st, envResolver(st, "http://legacy-unused"), clientFor, testDecrypt, stubFor(true), testLogger(t), "http://console", time.Second) + + poller.Tick(ctx) + bootstrapListCalls := api.listCalls + api.addEvent(2, "b", "cards/plugin.md", "ai", "Fix plugin") + poller.Tick(ctx) + poller.Tick(ctx) + + runs, err := st.ListRunsByService(ctx, service.ID, 10) + if err != nil || len(runs) != 1 { + t.Fatalf("in-column edit dispatched again: runs=%d err=%v", len(runs), err) + } + if api.listCalls != bootstrapListCalls+1 { + t.Fatalf("steady no-event tick performed a level rescan: list calls %d -> %d", bootstrapListCalls, api.listCalls) + } + spec, err := st.GetPluginAutomationSpec(ctx, automation.ID) + if err != nil || spec.Kanban.EventCursor != 2 { + t.Fatalf("event cursor = %+v, %v; want 2", spec.Kanban, err) + } + + // A durable event whose Card no longer appears in JType preserves the claim + // and history but marks the external reference unavailable. + delete(api.docs, "doc-plugin") + delete(api.contents, "doc-plugin") + api.addEvent(3, "b", "cards/plugin.md", "ai", "Fix plugin") + poller.Tick(ctx) + claim, err := st.GetPluginKanbanClaimByPath( + ctx, automation.ID, installation.WorkspaceID, "cards/plugin.md", + ) + if err != nil || claim.ExternalRefAvailable { + t.Fatalf("deleted Card claim=%+v err=%v; want unavailable", claim, err) + } + spec, err = st.GetPluginAutomationSpec(ctx, automation.ID) + if err != nil || spec.Kanban.EventCursor != 3 { + t.Fatalf("deleted Card cursor = %+v, %v; want 3", spec.Kanban, err) + } + + // If the stable Card returns at the same path, the next observation restores + // availability without creating a second in-column occurrence. + api.addCardWithoutEvent("doc-plugin", "cards/plugin.md", "b", "ai", "Fix plugin", "keep constraints", 4) + api.addEvent(4, "b", "cards/plugin.md", "ai", "Fix plugin") + poller.Tick(ctx) + claim, err = st.GetPluginKanbanClaimByPath( + ctx, automation.ID, installation.WorkspaceID, "cards/plugin.md", + ) + if err != nil || !claim.ExternalRefAvailable { + t.Fatalf("restored Card claim=%+v err=%v; want available", claim, err) + } +} + +func TestPluginKanbanAutomationStopsAtBoardDriftAndRecovers(t *testing.T) { + ctx := context.Background() + st := store.NewMemStore() + project := &domain.Project{ID: "drift-project", Name: "drift"} + service := &domain.Service{ + ID: "drift-service", ProjectID: project.ID, Name: "svc", + RepoKind: domain.RepoKindRaw, RawRepoURL: "https://git.test/acme/repo.git", + } + _ = st.CreateProject(ctx, project) + _ = st.CreateService(ctx, service) + cfg := &domain.ProviderConfig{Provider: domain.PluginJType, BaseURL: "http://jtype.plugin", PluginEnabled: true} + _ = st.UpsertProviderConfig(ctx, cfg) + installation := &domain.PluginInstallation{ + ID: "drift-installation", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "ws", AccessTokenEnc: []byte("PLUGINPAT"), + ConfigRevision: cfg.ConfigRevision, + } + _ = st.CreatePluginInstallation(ctx, installation) + automation := &domain.PluginAutomation{ + ID: "drift-automation", ServiceID: service.ID, InstallationID: installation.ID, + Name: "board", TriggerKind: "kanban", RunKind: domain.RunKindAgent, Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "b_stable", TriggerColumn: "ai", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + api := newFakeAPI() + poller := New(st, envResolver(st, "http://legacy-unused"), + func(_ *jtype.Factory, token string) DocumentAPI { return api }, + testDecrypt, stubFor(true), testLogger(t), "http://console", time.Second) + + poller.Tick(ctx) + api.addCardWithoutEvent( + "accepted-before-drift", "cards/accepted-before-drift.md", + "b_stable", "ai", "Resume accepted work", "", 1, + ) + existing, err := st.ObservePluginKanbanCard(ctx, store.PluginKanbanObservation{ + AutomationID: automation.ID, ServiceID: service.ID, InstallationID: installation.ID, + WorkspaceID: installation.WorkspaceID, DocumentID: "accepted-before-drift", + DocumentPath: "cards/accepted-before-drift.md", TriggerColumn: "ai", + DoneColumn: "done", ObservedColumn: "ai", EventKey: "event:accepted-before-drift", + ObservedAt: time.Now().UTC(), + }) + if err != nil || existing.Occurrence == nil { + t.Fatalf("existing occurrence=%+v err=%v", existing, err) + } + if _, err := st.SetPluginKanbanOccurrenceBlocked( + ctx, existing.Occurrence.ID, "model_not_configured", + "Choose an allowed model for this Service.", "project_owner", + ); err != nil { + t.Fatal(err) + } + api.configBoardMissing = true + api.addCard("drift-card", "cards/drift.md", "b_stable", "ai", "Do not dispatch", "", 1) + poller.Tick(ctx) + + spec, err := st.GetPluginAutomationSpec(ctx, automation.ID) + if err != nil || !strings.HasPrefix(spec.Automation.LastError, "board_drift:") { + t.Fatalf("last_error=%q err=%v", spec.Automation.LastError, err) + } + runs, _ := st.ListRunsByService(ctx, service.ID, 10) + if len(runs) != 1 || runs[0].OriginEventKey != existing.Occurrence.ID || + spec.Kanban.EventCursor != 0 { + t.Fatalf("drift did not resume only existing work: runs=%+v cursor=%d", runs, spec.Kanban.EventCursor) + } + + api.configBoardMissing = false + poller.Tick(ctx) + spec, err = st.GetPluginAutomationSpec(ctx, automation.ID) + runs, _ = st.ListRunsByService(ctx, service.ID, 10) + if err != nil || spec.Automation.LastError != "" || len(runs) != 2 { + t.Fatalf("recovery spec=%+v runs=%d err=%v", spec.Automation, len(runs), err) + } +} + +func TestPluginKanbanAutomationReentryRequiresTerminalWritebackAndNewLeave(t *testing.T) { + ctx := context.Background() + st := store.NewMemStore() + project := &domain.Project{ID: "reentry-project", Name: "reentry"} + service := &domain.Service{ID: "reentry-service", ProjectID: project.ID, Name: "svc", RepoKind: domain.RepoKindRaw, RawRepoURL: "https://git.test/acme/repo.git"} + _ = st.CreateProject(ctx, project) + _ = st.CreateService(ctx, service) + cfg := &domain.ProviderConfig{Provider: domain.PluginJType, BaseURL: "http://jtype.plugin", PluginEnabled: true} + _ = st.UpsertProviderConfig(ctx, cfg) + installation := &domain.PluginInstallation{ + ID: "reentry-installation", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "ws", AccessTokenEnc: []byte("PLUGINPAT"), + ConfigRevision: cfg.ConfigRevision, + } + _ = st.CreatePluginInstallation(ctx, installation) + automation := &domain.PluginAutomation{ + ID: "reentry-automation", ServiceID: service.ID, InstallationID: installation.ID, + Name: "board", TriggerKind: "kanban", RunKind: domain.RunKindAgent, Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "b", TriggerColumn: "ai", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + api := newFakeAPI() + api.addCardWithoutEvent("doc-plugin", "cards/plugin.md", "b", "ai", "Fix plugin", "keep constraints", 1) + poller := New(st, envResolver(st, "http://legacy-unused"), + func(_ *jtype.Factory, token string) DocumentAPI { return api }, + testDecrypt, stubFor(true), testLogger(t), "http://console", time.Second) + poller.Tick(ctx) + + runs, _ := st.ListRunsByService(ctx, service.ID, 10) + if len(runs) != 1 { + t.Fatalf("bootstrap runs=%d, want 1", len(runs)) + } + firstRun := runs[0] + firstClaim, err := st.GetPluginKanbanClaimByPath( + ctx, automation.ID, installation.WorkspaceID, "cards/plugin.md", + ) + if err != nil { + t.Fatal(err) + } + api.addEvent(2, "b", "cards/plugin.md", "todo", "Fix plugin") + api.addEvent(3, "b", "cards/plugin.md", "ai", "Fix plugin") + poller.Tick(ctx) + runs, _ = st.ListRunsByService(ctx, service.ID, 10) + if len(runs) != 1 { + t.Fatalf("active re-entry runs=%d, want 1", len(runs)) + } + activeReceipt := jtype.KanbanReceiptMarker( + firstClaim.LatestOccurrenceID, store.PluginKanbanAlreadyRunning, + ) + if !strings.Contains(strings.Join(api.comments["doc-plugin"], "\n"), activeReceipt) { + t.Fatalf("active re-entry comments=%+v, want %q", api.comments["doc-plugin"], activeReceipt) + } + + if _, err := st.ScheduleRun(ctx, firstRun.ID, "job", "token", "Scheduling"); err != nil { + t.Fatal(err) + } + if _, err := st.MarkRunning(ctx, firstRun.ID, "Running", time.Now().UTC()); err != nil { + t.Fatal(err) + } + if _, err := st.MarkSucceeded(ctx, firstRun.ID, "Succeeded", time.Now().UTC()); err != nil { + t.Fatal(err) + } + api.addEvent(4, "b", "cards/plugin.md", "todo", "Fix plugin") + api.addEvent(5, "b", "cards/plugin.md", "ai", "Fix plugin") + poller.Tick(ctx) + runs, _ = st.ListRunsByService(ctx, service.ID, 10) + if len(runs) != 1 { + t.Fatalf("writeback-pending re-entry runs=%d, want 1", len(runs)) + } + pendingReceipt := jtype.KanbanReceiptMarker( + firstClaim.LatestOccurrenceID, store.PluginKanbanWritebackPending, + ) + if !strings.Contains(strings.Join(api.comments["doc-plugin"], "\n"), pendingReceipt) { + t.Fatalf("pending re-entry comments=%+v, want %q", api.comments["doc-plugin"], pendingReceipt) + } + if wrote, err := st.MarkPluginKanbanWriteback( + ctx, automation.ID, "doc-plugin", firstClaim.LatestOccurrenceID, + domain.StatusSucceeded, nil, time.Now().UTC(), + ); err != nil || !wrote { + t.Fatalf("mark writeback=%v,%v", wrote, err) + } + api.addEvent(6, "b", "cards/plugin.md", "todo", "Fix plugin") + api.addEvent(7, "b", "cards/plugin.md", "ai", "Fix plugin") + poller.Tick(ctx) + runs, _ = st.ListRunsByService(ctx, service.ID, 10) + if len(runs) != 2 { + t.Fatalf("completed leave/re-entry runs=%d, want 2", len(runs)) + } + history, err := st.ListPluginKanbanOccurrences(ctx, automation.ID, "doc-plugin", 10) + if err != nil || len(history) != 2 { + t.Fatalf("history=%+v err=%v", history, err) + } +} + +func TestPluginKanbanBlockedOccurrenceRetriesWithoutNewBoardEvent(t *testing.T) { + ctx := context.Background() + st := store.NewMemStore() + project := &domain.Project{ID: "blocked-project", Name: "blocked"} + service := &domain.Service{ID: "blocked-service", ProjectID: project.ID, Name: "svc", RepoKind: domain.RepoKindRaw, RawRepoURL: "https://git.test/acme/repo.git"} + _ = st.CreateProject(ctx, project) + _ = st.CreateService(ctx, service) + cfg := &domain.ProviderConfig{Provider: domain.PluginJType, BaseURL: "http://jtype.plugin", PluginEnabled: true} + _ = st.UpsertProviderConfig(ctx, cfg) + installation := &domain.PluginInstallation{ + ID: "blocked-installation", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "ws", AccessTokenEnc: []byte("PLUGINPAT"), + ConfigRevision: cfg.ConfigRevision, + } + _ = st.CreatePluginInstallation(ctx, installation) + automation := &domain.PluginAutomation{ + ID: "blocked-automation", ServiceID: service.ID, InstallationID: installation.ID, + Name: "board", TriggerKind: "kanban", RunKind: domain.RunKindAgent, Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "b", TriggerColumn: "ai", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + api := newFakeAPI() + api.addCardWithoutEvent("blocked-card", "cards/blocked.md", "b", "ai", "Fix model", "body", 1) + models := modelStub{outcome: modelcfg.SelectNotConfigured} + poller := New(st, envResolver(st, "http://legacy-unused"), + func(_ *jtype.Factory, token string) DocumentAPI { return api }, + testDecrypt, &models, testLogger(t), "http://console", time.Second) + + poller.Tick(ctx) + poller.Tick(ctx) + runs, _ := st.ListRunsByService(ctx, service.ID, 10) + history, err := st.ListPluginKanbanOccurrences(ctx, automation.ID, "blocked-card", 10) + if err != nil || len(runs) != 0 || len(history) != 1 || + history[0].State != domain.KanbanOccurrenceBlocked || + history[0].ReasonCode != "model_not_configured" { + t.Fatalf("blocked state: runs=%d history=%+v err=%v", len(runs), history, err) + } + if comments := api.comments["blocked-card"]; len(comments) != 1 || + !strings.Contains(comments[0], ":blocked -->") { + t.Fatalf("blocked receipt comments=%q", comments) + } + + models.outcome = modelcfg.SelectOK + poller.Tick(ctx) + runs, _ = st.ListRunsByService(ctx, service.ID, 10) + history, err = st.ListPluginKanbanOccurrences(ctx, automation.ID, "blocked-card", 10) + if err != nil || len(runs) != 1 || len(history) != 1 || + history[0].State != domain.KanbanOccurrenceQueued || + history[0].RunID != runs[0].ID { + t.Fatalf("resumed state: runs=%+v history=%+v err=%v", runs, history, err) + } + if comments := api.comments["blocked-card"]; len(comments) != 2 || + !strings.Contains(comments[1], ":accepted -->") { + t.Fatalf("resumed receipt comments=%q", comments) + } +} + +func TestPluginKanbanCardReadFailureIsVisibleAndRetriesSameOccurrence(t *testing.T) { + ctx := context.Background() + st := store.NewMemStore() + project := &domain.Project{ID: "card-read-project", Name: "card-read"} + service := &domain.Service{ + ID: "card-read-service", ProjectID: project.ID, Name: "svc", + RepoKind: domain.RepoKindRaw, RawRepoURL: "https://git.test/acme/repo.git", + } + _ = st.CreateProject(ctx, project) + _ = st.CreateService(ctx, service) + cfg := &domain.ProviderConfig{ + Provider: domain.PluginJType, BaseURL: "http://jtype.plugin", PluginEnabled: true, + } + _ = st.UpsertProviderConfig(ctx, cfg) + installation := &domain.PluginInstallation{ + ID: "card-read-installation", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "ws", + AccessTokenEnc: []byte("PLUGINPAT"), ConfigRevision: cfg.ConfigRevision, + } + _ = st.CreatePluginInstallation(ctx, installation) + automation := &domain.PluginAutomation{ + ID: "card-read-automation", ServiceID: service.ID, InstallationID: installation.ID, + Name: "board", TriggerKind: "kanban", RunKind: domain.RunKindAgent, Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "b", TriggerColumn: "ai", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + api := newFakeAPI() + poller := New(st, envResolver(st, "http://legacy-unused"), + func(_ *jtype.Factory, token string) DocumentAPI { return api }, + testDecrypt, stubFor(true), testLogger(t), "http://console", time.Second) + + // Establish the one-time event cursor without an initial Card, then create + // one durable transition whose full Card read fails transiently. + poller.Tick(ctx) + api.addCard("card-read", "cards/card-read.md", "b", "ai", "Read me", "body", 1) + api.getErrOnce["card-read"] = &jtype.Error{ + StatusCode: 503, Code: "unavailable", Message: "retry", + } + poller.Tick(ctx) + + history, err := st.ListPluginKanbanOccurrences(ctx, automation.ID, "card-read", 10) + if err != nil || len(history) != 1 || + history[0].State != domain.KanbanOccurrenceBlocked || + history[0].ReasonCode != "card_read_unavailable" { + t.Fatalf("visible Card read blocker: history=%+v err=%v", history, err) + } + spec, err := st.GetPluginAutomationSpec(ctx, automation.ID) + if err != nil || + !strings.HasPrefix(spec.Automation.LastError, "card_read_unavailable:") { + t.Fatalf("automation error=%q err=%v", spec.Automation.LastError, err) + } + if runs, _ := st.ListRunsByService(ctx, service.ID, 10); len(runs) != 0 { + t.Fatalf("Card read failure dispatched %d runs", len(runs)) + } + occurrenceID := history[0].ID + + // No new board event is needed. The retry attaches exactly one Run to the + // already persisted occurrence and advances its receipt to accepted. + poller.Tick(ctx) + history, err = st.ListPluginKanbanOccurrences(ctx, automation.ID, "card-read", 10) + runs, _ := st.ListRunsByService(ctx, service.ID, 10) + if err != nil || len(history) != 1 || history[0].ID != occurrenceID || + history[0].State != domain.KanbanOccurrenceQueued || + len(runs) != 1 || history[0].RunID != runs[0].ID { + t.Fatalf("Card read recovery: history=%+v runs=%+v err=%v", history, runs, err) + } + if spec, err = st.GetPluginAutomationSpec(ctx, automation.ID); err != nil || + spec.Automation.LastError != "" { + t.Fatalf("recovered automation error=%q err=%v", spec.Automation.LastError, err) + } +} + +func TestPluginKanbanMissingCardMarksClaimUnavailable(t *testing.T) { + ctx := context.Background() + st := store.NewMemStore() + project := &domain.Project{ID: "missing-card-project", Name: "missing-card"} + service := &domain.Service{ + ID: "missing-card-service", ProjectID: project.ID, Name: "svc", + RepoKind: domain.RepoKindRaw, RawRepoURL: "https://git.test/acme/repo.git", + } + _ = st.CreateProject(ctx, project) + _ = st.CreateService(ctx, service) + cfg := &domain.ProviderConfig{ + Provider: domain.PluginJType, BaseURL: "http://jtype.plugin", PluginEnabled: true, + } + _ = st.UpsertProviderConfig(ctx, cfg) + installation := &domain.PluginInstallation{ + ID: "missing-card-installation", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "ws", + AccessTokenEnc: []byte("PLUGINPAT"), ConfigRevision: cfg.ConfigRevision, + } + if err := st.CreatePluginInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + automation := &domain.PluginAutomation{ + ID: "missing-card-automation", ServiceID: service.ID, + InstallationID: installation.ID, + Name: "board", TriggerKind: "kanban", Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "b", TriggerColumn: "ai", + } + if err := st.CreatePluginAutomation( + ctx, automation, nil, nil, trigger, nil, + ); err != nil { + t.Fatal(err) + } + api := newFakeAPI() + result, err := st.ObservePluginKanbanCard(ctx, store.PluginKanbanObservation{ + AutomationID: automation.ID, ServiceID: service.ID, + InstallationID: installation.ID, WorkspaceID: "ws", + DocumentID: "missing-card", DocumentPath: "cards/missing.md", + TriggerColumn: "ai", ObservedColumn: "ai", + EventKey: "event:1", ObservedAt: time.Now().UTC(), + }) + if err != nil || result.Occurrence == nil { + t.Fatalf("seed occurrence=%+v err=%v", result, err) + } + api.getErrOnce["missing-card"] = &jtype.Error{ + StatusCode: 404, Code: "not_found", Message: "gone", + } + poller := &Poller{st: st, models: stubFor(true), now: func() time.Time { + return time.Now().UTC() + }} + poller.dispatchPluginKanbanOccurrence( + ctx, api, &domain.PluginAutomationSpec{Automation: *automation}, + result.Occurrence, nil, + ) + + claim, err := st.GetPluginKanbanClaimByPath( + ctx, automation.ID, "ws", "cards/missing.md", + ) + if err != nil || claim.ExternalRefAvailable { + t.Fatalf("missing Card claim=%+v err=%v", claim, err) + } + history, err := st.ListPluginKanbanOccurrences( + ctx, automation.ID, "missing-card", 10, + ) + if err != nil || len(history) != 1 || + history[0].ReasonCode != "card_unavailable" { + t.Fatalf("missing Card history=%+v err=%v", history, err) + } +} + +func TestPluginKanbanRepositoryBlockersAreTyped(t *testing.T) { + ctx := context.Background() + st := store.NewMemStore() + project := &domain.Project{ID: "repository-gate-project", Name: "repository-gate"} + if err := st.CreateProject(ctx, project); err != nil { + t.Fatal(err) + } + raw := &domain.Service{ + ID: "raw-service", ProjectID: project.ID, Name: "raw", + RepoKind: domain.RepoKindRaw, + } + if err := st.CreateService(ctx, raw); err != nil { + t.Fatal(err) + } + providerService := &domain.Service{ + ID: "provider-service", ProjectID: project.ID, Name: "provider", + RepoKind: domain.RepoKindProvider, Provider: domain.ProviderGitea, + RepoOwnerName: "acme/repo", + } + if err := st.CreateService(ctx, providerService); err != nil { + t.Fatal(err) + } + poller := &Poller{st: st} + + code, _, role := poller.pluginKanbanRepositoryBlocker(ctx, raw) + if code != "repository_not_configured" || role != "project_owner" { + t.Fatalf("raw blocker=%q role=%q", code, role) + } + raw.RawRepoURL = "https://git.test/acme/repo.git" + if code, _, _ = poller.pluginKanbanRepositoryBlocker(ctx, raw); code != "" { + t.Fatalf("healthy raw repository blocker=%q", code) + } + code, _, role = poller.pluginKanbanRepositoryBlocker(ctx, providerService) + if code != "repository_unavailable" || role != "project_owner" { + t.Fatalf("provider blocker=%q role=%q", code, role) + } } // A second tick must not re-dispatch (idempotent), and the durable cursor must suppress diff --git a/orchestrator/internal/reconciler/kanban_test.go b/orchestrator/internal/reconciler/kanban_test.go index 15a14e7b..22b17d1c 100644 --- a/orchestrator/internal/reconciler/kanban_test.go +++ b/orchestrator/internal/reconciler/kanban_test.go @@ -3,6 +3,7 @@ package reconciler import ( "context" "errors" + "fmt" "io" "log/slog" "strings" @@ -32,11 +33,13 @@ func testDecrypt(b []byte) (string, error) { return "PLAIN-" + string(b), nil } // tokens records every PAT the token->writer factory was asked to bind, so the // per-link token-selection tests can assert which credential was used. type fakeKanbanWriter struct { - comments []commentCall - moves []moveCall - commentErr error - moveErr error - tokens []string + comments []commentCall + moves []moveCall + commentErr error + listErr error + moveErr error + moveErrOnce bool + tokens []string } // writerFor returns fk as a (factory,token)->writer builder, recording each @@ -69,7 +72,24 @@ func (f *fakeKanbanWriter) AddComment(_ context.Context, ws, docID, body string) return nil } +func (f *fakeKanbanWriter) ListComments(_ context.Context, ws, docID string) ([]jtype.Comment, error) { + if f.listErr != nil { + return nil, f.listErr + } + out := make([]jtype.Comment, 0) + for index, comment := range f.comments { + if comment.ws == ws && comment.docID == docID { + out = append(out, jtype.Comment{ID: fmt.Sprintf("comment-%d", index+1), Body: comment.body}) + } + } + return out, nil +} + func (f *fakeKanbanWriter) MoveCard(_ context.Context, ws, docID, status string) error { + if f.moveErrOnce { + f.moveErrOnce = false + return errors.New("temporary move failure") + } if f.moveErr != nil { return f.moveErr } @@ -177,14 +197,20 @@ func TestPluginKanbanWritebackUsesInstallationWorkspace(t *testing.T) { t.Fatal(err) } run := &domain.Run{ID: domain.NewID(), ProjectID: project.ID, ServiceID: service.ID, Prompt: "p", Status: domain.StatusSucceeded, Origin: domain.RunOriginKanban, OriginAutomationID: automation.ID, Attempt: 1, CreatedAt: time.Now()} - if err := st.CreateRun(ctx, run); err != nil { - t.Fatal(err) - } - if _, err := st.EnsurePluginKanbanClaim(ctx, automation.ID, "doc-plugin", "cards/x.md", "workspace-fixed", "done"); err != nil { - t.Fatal(err) - } - if err := st.SetPluginKanbanClaimRun(ctx, automation.ID, "doc-plugin", run.ID); err != nil { - t.Fatal(err) + observed, err := st.ObservePluginKanbanCard(ctx, store.PluginKanbanObservation{ + AutomationID: automation.ID, ServiceID: service.ID, + InstallationID: installation.ID, WorkspaceID: installation.WorkspaceID, + DocumentID: "doc-plugin", DocumentPath: "cards/x.md", + TriggerColumn: "ai", DoneColumn: "done", ObservedColumn: "ai", + EventKey: "event:1", ObservedAt: time.Now().UTC(), + }) + if err != nil || observed.Occurrence == nil { + t.Fatalf("observe occurrence=%+v err=%v", observed, err) + } + if attached, err := st.CreatePluginKanbanOccurrenceRun( + ctx, observed.Occurrence.ID, run, + ); err != nil || !attached { + t.Fatalf("attach run=%v err=%v", attached, err) } if err := st.CreateRunPluginSnapshots(ctx, []domain.RunPluginSnapshot{{RunID: run.ID, InstallationID: installation.ID, CreatedAt: time.Now()}}); err != nil { t.Fatal(err) @@ -199,21 +225,103 @@ func TestPluginKanbanWritebackUsesInstallationWorkspace(t *testing.T) { if err := st.DeletePluginAutomation(ctx, automation.ID); err != nil { t.Fatal(err) } - writer := &fakeKanbanWriter{} + writer := &fakeKanbanWriter{moveErrOnce: true} rec := wire(st, newWritebackRec(st), writer, "http://console") rec.Tick(ctx) rec.Tick(ctx) if len(writer.moves) != 1 || writer.moves[0].ws != "workspace-fixed" || writer.moves[0].status != "done" { t.Fatalf("moves=%+v", writer.moves) } - if len(writer.comments) != 1 || writer.comments[0].docID != "doc-plugin" { + if len(writer.comments) != 2 || + writer.comments[0].docID != "doc-plugin" || + writer.comments[1].docID != "doc-plugin" { t.Fatalf("comments=%+v", writer.comments) } + if !strings.Contains(writer.comments[0].body, ":accepted -->") || + !strings.Contains(writer.comments[1].body, ":terminal -->") { + t.Fatalf("receipt phase order=%+v", writer.comments) + } if len(writer.tokens) == 0 || writer.tokens[0] != "PLAIN-PLUGINPAT" { t.Fatalf("tokens=%v", writer.tokens) } } +func TestPluginKanbanDeletedCardCompletesAsUnavailable(t *testing.T) { + ctx := context.Background() + st := store.NewMemStore() + project := &domain.Project{ID: "deleted-card-project", Name: "p", CreatedAt: time.Now()} + _ = st.CreateProject(ctx, project) + service := &domain.Service{ + ID: "deleted-card-service", ProjectID: project.ID, Name: "svc", + RepoKind: domain.RepoKindRaw, RawRepoURL: "https://git.test/acme/repo.git", + CreatedAt: time.Now(), + } + _ = st.CreateService(ctx, service) + cfg := &domain.ProviderConfig{ + Provider: domain.PluginJType, BaseURL: "http://jtype.plugin", PluginEnabled: true, + } + _ = st.UpsertProviderConfig(ctx, cfg) + installation := &domain.PluginInstallation{ + ID: "deleted-card-installation", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "workspace-fixed", + AccessTokenEnc: []byte("PLUGINPAT"), ConfigRevision: cfg.ConfigRevision, + ConsentedAt: time.Now(), CreatedAt: time.Now(), + } + _ = st.CreatePluginInstallation(ctx, installation) + automation := &domain.PluginAutomation{ + ID: "deleted-card-automation", ServiceID: service.ID, InstallationID: installation.ID, + Name: "board", TriggerKind: "kanban", Enabled: true, CreatedAt: time.Now(), + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "b", TriggerColumn: "ai", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + run := &domain.Run{ + ID: "deleted-card-run", ProjectID: project.ID, ServiceID: service.ID, + Status: domain.StatusSucceeded, Origin: domain.RunOriginKanban, + OriginAutomationID: automation.ID, CreatedAt: time.Now(), + } + if err := st.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + if _, err := st.EnsurePluginKanbanClaim( + ctx, automation.ID, "deleted-card", "cards/deleted.md", "workspace-fixed", "done", + ); err != nil { + t.Fatal(err) + } + if err := st.SetPluginKanbanClaimRun(ctx, automation.ID, "deleted-card", run.ID); err != nil { + t.Fatal(err) + } + if err := st.CreateRunPluginSnapshots(ctx, []domain.RunPluginSnapshot{{ + RunID: run.ID, InstallationID: installation.ID, CreatedAt: time.Now(), + }}); err != nil { + t.Fatal(err) + } + writer := &fakeKanbanWriter{listErr: jtype.ErrDocNotFound} + rec := wire(st, newWritebackRec(st), writer, "http://console") + rec.Tick(ctx) + rec.Tick(ctx) + + if pending, err := st.ListPluginKanbanRunsAwaitingWriteback(ctx); err != nil || len(pending) != 0 { + t.Fatalf("pending=%+v err=%v", pending, err) + } + claim, err := st.GetPluginKanbanClaimByPath( + ctx, automation.ID, "workspace-fixed", "cards/deleted.md", + ) + if err != nil || claim.ExternalRefAvailable { + t.Fatalf("claim=%+v err=%v", claim, err) + } + history, err := st.ListPluginKanbanOccurrences(ctx, automation.ID, "deleted-card", 10) + if err != nil || len(history) != 1 || + history[0].WritebackState != "unavailable" || + history[0].Outcome != string(domain.StatusSucceeded) { + t.Fatalf("history=%+v err=%v", history, err) + } +} + func TestWritebackFailedPostsReasonNoMove(t *testing.T) { ctx := context.Background() st := store.NewMemStore() diff --git a/orchestrator/internal/reconciler/reconciler.go b/orchestrator/internal/reconciler/reconciler.go index 3b0b6dff..93fbb382 100644 --- a/orchestrator/internal/reconciler/reconciler.go +++ b/orchestrator/internal/reconciler/reconciler.go @@ -156,6 +156,7 @@ func workspaceArchiveKey(serviceID string) string { // KanbanWriter is the slice of *jtype.Client the writeback pass uses. Exported so // main.go can build the token->writer factory; a fake implements it in tests. type KanbanWriter interface { + ListComments(ctx context.Context, workspace, docID string) ([]jtype.Comment, error) AddComment(ctx context.Context, workspace, docID, body string) error MoveCard(ctx context.Context, workspace, docID, newStatus string) error } @@ -1066,16 +1067,102 @@ func (r *Reconciler) reconcilePluginKanbanWriteback(ctx context.Context) { continue } writer := r.kanbanFor(factory, token) + occurrenceID := wb.Claim.LatestOccurrenceID + if wb.Occurrence != nil { + occurrenceID = wb.Occurrence.ID + } + if occurrenceID == "" { + continue + } + comments, err := writer.ListComments(ctx, wb.Claim.WorkspaceID, wb.Claim.DocumentID) + if err != nil { + r.finishUnavailablePluginKanbanWriteback(ctx, wb, err) + continue + } + acceptedMarker := jtype.KanbanReceiptMarker(occurrenceID, "accepted") + terminalMarker := jtype.KanbanReceiptMarker(occurrenceID, "terminal") + acceptedWritten := false + terminalWritten := false + for _, comment := range comments { + acceptedWritten = acceptedWritten || strings.Contains(comment.Body, acceptedMarker) + terminalWritten = terminalWritten || strings.Contains(comment.Body, terminalMarker) + } + // The terminal result must not supersede a pending accepted receipt. A + // very fast Run can finish while the poller's first comment is retrying; + // write/dedupe that receipt here before publishing the terminal phase. + if wb.Occurrence != nil && wb.Occurrence.ReceiptPhase == "accepted" && + wb.Occurrence.ReceiptWrittenAt == nil { + if !acceptedWritten { + service, serviceErr := r.st.GetService(ctx, wb.Run.ServiceID) + if serviceErr != nil { + continue + } + model := wb.Run.ModelName + if model == "" { + model = "the configured model" + } + body := acceptedMarker + "\n" + + "jcode Cloud accepted this Card for `" + service.Name + + "` using `" + model + "`." + if r.consoleURL != "" { + body += "\n\nRun: " + strings.TrimRight(r.consoleURL, "/") + + "/runs/" + wb.Run.ID + } + if err := writer.AddComment( + ctx, wb.Claim.WorkspaceID, wb.Claim.DocumentID, body, + ); err != nil { + r.finishUnavailablePluginKanbanWriteback(ctx, wb, err) + continue + } + } + writtenAt := r.now() + if err := r.st.MarkPluginKanbanOccurrenceReceipt( + ctx, occurrenceID, "accepted", &writtenAt, "", + ); err != nil { + continue + } + } + if !terminalWritten { + body := terminalMarker + "\n" + kanbanCommentBody(&wb.Run, r.consoleURL) + if err := writer.AddComment(ctx, wb.Claim.WorkspaceID, wb.Claim.DocumentID, body); err != nil { + r.finishUnavailablePluginKanbanWriteback(ctx, wb, err) + continue + } + } if wb.Run.Status == domain.StatusSucceeded && wb.Claim.DoneColumn != "" { if err := writer.MoveCard(ctx, wb.Claim.WorkspaceID, wb.Claim.DocumentID, wb.Claim.DoneColumn); err != nil { + r.finishUnavailablePluginKanbanWriteback(ctx, wb, err) continue } } - if err := writer.AddComment(ctx, wb.Claim.WorkspaceID, wb.Claim.DocumentID, kanbanCommentBody(&wb.Run, r.consoleURL)); err != nil { - continue - } - _, _ = r.st.MarkPluginKanbanWriteback(ctx, wb.Claim.AutomationID, wb.Claim.DocumentID, r.now()) + _, _ = r.st.MarkPluginKanbanWriteback( + ctx, wb.Claim.AutomationID, wb.Claim.DocumentID, occurrenceID, + wb.Run.Status, wb.Run.FinishedAt, r.now(), + ) + } +} + +func (r *Reconciler) finishUnavailablePluginKanbanWriteback(ctx context.Context, wb *store.PluginKanbanWriteback, cause error) { + if wb == nil || !isJTypeNotFound(cause) { + return + } + occurrenceID := wb.Claim.LatestOccurrenceID + if wb.Occurrence != nil { + occurrenceID = wb.Occurrence.ID + } + _, _ = r.st.MarkPluginKanbanWritebackUnavailable( + ctx, wb.Claim.AutomationID, wb.Claim.DocumentID, occurrenceID, + wb.Run.Status, wb.Run.FinishedAt, + "The source Card is no longer available in JType.", r.now(), + ) +} + +func isJTypeNotFound(err error) bool { + if errors.Is(err, jtype.ErrDocNotFound) { + return true } + var apiErr *jtype.Error + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound } // writebackCard posts the result comment for one terminal run and (for a diff --git a/orchestrator/internal/store/memory.go b/orchestrator/internal/store/memory.go index f2eb7c19..da19560f 100644 --- a/orchestrator/internal/store/memory.go +++ b/orchestrator/internal/store/memory.go @@ -43,6 +43,7 @@ type MemStore struct { pluginSCMTriggers map[string]domain.SCMTrigger pluginKanbanTriggers map[string]domain.KanbanTrigger pluginKanbanClaims map[string]domain.PluginKanbanClaim + pluginKanbanOccurrences map[string]domain.PluginKanbanOccurrence pluginCronTriggers map[string]domain.CronTrigger webhookReceipts map[string]domain.WebhookReceipt // provider|delivery id webhookReceiptDigests map[string]string // provider|authenticated payload digest -> receipt key @@ -101,6 +102,7 @@ func NewMemStore() *MemStore { pluginSCMTriggers: map[string]domain.SCMTrigger{}, pluginKanbanTriggers: map[string]domain.KanbanTrigger{}, pluginKanbanClaims: map[string]domain.PluginKanbanClaim{}, + pluginKanbanOccurrences: map[string]domain.PluginKanbanOccurrence{}, pluginCronTriggers: map[string]domain.CronTrigger{}, webhookReceipts: map[string]domain.WebhookReceipt{}, webhookReceiptDigests: map[string]string{}, @@ -439,6 +441,12 @@ func (m *MemStore) deleteRunLocked(runID string) { m.pluginKanbanClaims[key] = claim } } + for key, occurrence := range m.pluginKanbanOccurrences { + if occurrence.RunID == runID { + occurrence.RunID = "" + m.pluginKanbanOccurrences[key] = occurrence + } + } } // ListArchiveCandidates mirrors the PG query (F10): a service not already @@ -2785,7 +2793,7 @@ func (m *MemStore) deletePluginAutomationLocked(automationID string) { } } for key, claim := range m.pluginKanbanClaims { - if claim.AutomationID == automationID && claim.RunID == "" { + if claim.AutomationID == automationID && claim.RunID == "" && claim.LatestOccurrenceID == "" { delete(m.pluginKanbanClaims, key) } } @@ -3237,7 +3245,12 @@ func (m *MemStore) EnsurePluginKanbanClaim(_ context.Context, automationID, docu if !exists { return nil, ErrNotFound } - claim = domain.PluginKanbanClaim{AutomationID: automationID, InstallationID: trigger.InstallationID, DocumentID: documentID, DocumentPath: documentPath, WorkspaceID: workspaceID, DoneColumn: doneColumn, CreatedAt: time.Now().UTC()} + now := time.Now().UTC() + claim = domain.PluginKanbanClaim{ + AutomationID: automationID, InstallationID: trigger.InstallationID, + DocumentID: documentID, DocumentPath: documentPath, WorkspaceID: workspaceID, + DoneColumn: doneColumn, ExternalRefAvailable: true, CreatedAt: now, UpdatedAt: now, + } m.pluginKanbanClaims[key] = claim } copy := claim @@ -3255,7 +3268,26 @@ func (m *MemStore) SetPluginKanbanClaimRun(_ context.Context, automationID, docu if claim.RunID != "" { return ErrAlreadyExists } + if claim.LatestOccurrenceID == "" { + automation, automationOK := m.pluginAutomations[automationID] + trigger, triggerOK := m.pluginKanbanTriggers[automationID] + if !automationOK || !triggerOK { + return ErrNotFound + } + now := time.Now().UTC() + occurrence := domain.PluginKanbanOccurrence{ + ID: domain.NewID(), AutomationID: automationID, ServiceID: automation.ServiceID, + InstallationID: claim.InstallationID, WorkspaceID: claim.WorkspaceID, + DocumentID: documentID, DocumentPath: claim.DocumentPath, DoneColumn: claim.DoneColumn, + EventKey: "legacy:" + documentID + ":" + runID, EntryColumn: trigger.TriggerColumn, + State: domain.KanbanOccurrenceQueued, RunID: runID, WritebackState: "pending", + CreatedAt: now, UpdatedAt: now, + } + m.pluginKanbanOccurrences[occurrence.ID] = occurrence + claim.LatestOccurrenceID = occurrence.ID + } claim.RunID = runID + claim.UpdatedAt = time.Now().UTC() m.pluginKanbanClaims[key] = claim return nil } @@ -3272,12 +3304,31 @@ func (m *MemStore) ListPluginKanbanRunsAwaitingWriteback(_ context.Context) ([]P if !ok || !run.Status.Terminal() { continue } - out = append(out, PluginKanbanWriteback{Claim: claim, Run: run}) + var occurrence *domain.PluginKanbanOccurrence + if claim.LatestOccurrenceID != "" { + if value, exists := m.pluginKanbanOccurrences[claim.LatestOccurrenceID]; exists { + if value.WritebackState != "pending" { + continue + } + copy := value + occurrence = © + } + } + if occurrence == nil { + continue + } + out = append(out, PluginKanbanWriteback{Claim: claim, Occurrence: occurrence, Run: run}) } return out, nil } -func (m *MemStore) MarkPluginKanbanWriteback(_ context.Context, automationID, documentID string, at time.Time) (bool, error) { +func (m *MemStore) MarkPluginKanbanWriteback( + _ context.Context, + automationID, documentID, occurrenceID string, + outcome domain.RunStatus, + terminalAt *time.Time, + at time.Time, +) (bool, error) { m.mu.Lock() defer m.mu.Unlock() key := pluginKanbanClaimKey(automationID, documentID) @@ -3285,11 +3336,69 @@ func (m *MemStore) MarkPluginKanbanWriteback(_ context.Context, automationID, do if !ok { return false, ErrNotFound } - if claim.WritebackAt != nil { + if claim.WritebackAt != nil || claim.LatestOccurrenceID != occurrenceID { return false, nil } claim.WritebackAt = &at + claim.UpdatedAt = at m.pluginKanbanClaims[key] = claim + if claim.LatestOccurrenceID != "" { + if occurrence, ok := m.pluginKanbanOccurrences[claim.LatestOccurrenceID]; ok { + occurrence.State = domain.KanbanOccurrenceTerminal + occurrence.WritebackState = "complete" + occurrence.ReceiptPhase = "terminal" + occurrence.ReceiptWrittenAt = &at + finishedAt := at + if terminalAt != nil { + finishedAt = *terminalAt + } + occurrence.TerminalAt = &finishedAt + occurrence.UpdatedAt = at + occurrence.Outcome = string(outcome) + m.pluginKanbanOccurrences[claim.LatestOccurrenceID] = occurrence + } + } + return true, nil +} + +func (m *MemStore) MarkPluginKanbanWritebackUnavailable( + _ context.Context, + automationID, documentID, occurrenceID string, + outcome domain.RunStatus, + terminalAt *time.Time, + message string, + at time.Time, +) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + key := pluginKanbanClaimKey(automationID, documentID) + claim, ok := m.pluginKanbanClaims[key] + if !ok { + return false, ErrNotFound + } + if claim.WritebackAt != nil || claim.LatestOccurrenceID != occurrenceID { + return false, nil + } + claim.WritebackAt = &at + claim.ExternalRefAvailable = false + claim.LastObservedColumn = "" + claim.OutsideTriggerAt = &at + claim.UpdatedAt = at + m.pluginKanbanClaims[key] = claim + if occurrence, exists := m.pluginKanbanOccurrences[claim.LatestOccurrenceID]; exists { + occurrence.State = domain.KanbanOccurrenceTerminal + occurrence.WritebackState = "unavailable" + occurrence.WritebackError = message + occurrence.ReceiptPhase = "terminal" + finishedAt := at + if terminalAt != nil { + finishedAt = *terminalAt + } + occurrence.TerminalAt = &finishedAt + occurrence.UpdatedAt = at + occurrence.Outcome = string(outcome) + m.pluginKanbanOccurrences[claim.LatestOccurrenceID] = occurrence + } return true, nil } diff --git a/orchestrator/internal/store/migrations/0058_kanban_execution_occurrences.sql b/orchestrator/internal/store/migrations/0058_kanban_execution_occurrences.sql new file mode 100644 index 00000000..29fdc0c0 --- /dev/null +++ b/orchestrator/internal/store/migrations/0058_kanban_execution_occurrences.sql @@ -0,0 +1,103 @@ +-- Durable event consumption and repeatable Card-entry execution history. +-- Occurrences intentionally do not reference Automation/Service/Installation: +-- deleting a binding must not erase execution evidence or frozen writeback +-- routing. The Run reference is nullable so ordinary run retention can proceed. + +ALTER TABLE automation_kanban_triggers + ADD COLUMN IF NOT EXISTS event_cursor BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS bootstrapped_at TIMESTAMPTZ; + +ALTER TABLE automation_kanban_claims + ADD COLUMN IF NOT EXISTS last_observed_column TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS outside_trigger_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS latest_occurrence_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS external_ref_available BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +CREATE TABLE IF NOT EXISTS automation_kanban_occurrences ( + id TEXT PRIMARY KEY, + automation_id TEXT NOT NULL, + service_id TEXT NOT NULL DEFAULT '', + installation_id TEXT NOT NULL DEFAULT '', + workspace_id TEXT NOT NULL DEFAULT '', + document_id TEXT NOT NULL, + document_path TEXT NOT NULL DEFAULT '', + done_column TEXT NOT NULL DEFAULT '', + event_key TEXT NOT NULL, + event_sequence BIGINT, + actor_display TEXT NOT NULL DEFAULT '', + entry_column TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('received','blocked','queued','running','terminal')), + outcome TEXT NOT NULL DEFAULT '' CHECK (outcome IN ('','succeeded','failed','canceled')), + reason_code TEXT NOT NULL DEFAULT '', + reason_message TEXT NOT NULL DEFAULT '', + repair_role TEXT NOT NULL DEFAULT '' CHECK (repair_role IN ('','project_owner','cluster_admin')), + run_id TEXT REFERENCES runs(id) ON DELETE SET NULL, + receipt_phase TEXT NOT NULL DEFAULT '', + receipt_written_at TIMESTAMPTZ, + writeback_state TEXT NOT NULL DEFAULT 'pending' CHECK (writeback_state IN ('not_required','pending','complete','unavailable')), + writeback_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + terminal_at TIMESTAMPTZ, + UNIQUE (automation_id, event_key) +); + +CREATE UNIQUE INDEX IF NOT EXISTS automation_kanban_occurrences_run_uq + ON automation_kanban_occurrences (run_id) WHERE run_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS automation_kanban_occurrences_claim_idx + ON automation_kanban_occurrences (automation_id, document_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS automation_kanban_occurrences_card_idx + ON automation_kanban_occurrences (service_id, workspace_id, document_path, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS automation_kanban_occurrences_retry_idx + ON automation_kanban_occurrences (state, updated_at) + WHERE state IN ('received','blocked'); + +-- Preserve the single-run behavior shipped before occurrences as a historical +-- occurrence. New code treats claim.run_id/writeback_at only as a compatibility +-- projection of the latest occurrence. +INSERT INTO automation_kanban_occurrences ( + id, automation_id, service_id, installation_id, workspace_id, + document_id, document_path, done_column, event_key, entry_column, + state, outcome, run_id, receipt_phase, writeback_state, + receipt_written_at, created_at, updated_at, terminal_at +) +SELECT + 'occ_legacy_' || md5(c.automation_id || ':' || c.document_id), + c.automation_id, + COALESCE(a.service_id, r.service_id, ''), + c.installation_id, + c.workspace_id, + c.document_id, + c.document_path, + c.done_column, + 'legacy:' || c.document_id, + COALESCE(t.trigger_column, ''), + CASE + WHEN r.status IN ('succeeded','failed','canceled') THEN 'terminal' + ELSE 'queued' + END, + CASE + WHEN r.status IN ('succeeded','failed','canceled') THEN r.status + ELSE '' + END, + c.run_id, + CASE WHEN c.writeback_at IS NOT NULL THEN 'terminal' ELSE '' END, + CASE WHEN c.writeback_at IS NOT NULL THEN 'complete' ELSE 'pending' END, + c.writeback_at, + c.created_at, + COALESCE(c.writeback_at, c.created_at), + CASE WHEN r.status IN ('succeeded','failed','canceled') THEN r.finished_at END +FROM automation_kanban_claims c +JOIN runs r ON r.id = c.run_id +LEFT JOIN automations_v2 a ON a.id = c.automation_id +LEFT JOIN automation_kanban_triggers t ON t.automation_id = c.automation_id +ON CONFLICT DO NOTHING; + +UPDATE automation_kanban_claims c +SET latest_occurrence_id = o.id, + updated_at = GREATEST(c.updated_at, o.updated_at) +FROM automation_kanban_occurrences o +WHERE o.automation_id = c.automation_id + AND o.document_id = c.document_id + AND c.latest_occurrence_id = ''; diff --git a/orchestrator/internal/store/migrations/0059_kanban_column_labels.sql b/orchestrator/internal/store/migrations/0059_kanban_column_labels.sql new file mode 100644 index 00000000..96531d6f --- /dev/null +++ b/orchestrator/internal/store/migrations/0059_kanban_column_labels.sql @@ -0,0 +1,14 @@ +-- Preserve human-readable Kanban column names independently of stable column +-- keys so policy and receipts remain understandable after the board is linked. + +ALTER TABLE automation_kanban_triggers + ADD COLUMN IF NOT EXISTS trigger_label TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS done_label TEXT NOT NULL DEFAULT ''; + +UPDATE automation_kanban_triggers +SET trigger_label = trigger_column +WHERE trigger_label = ''; + +UPDATE automation_kanban_triggers +SET done_label = done_column +WHERE done_column <> '' AND done_label = ''; diff --git a/orchestrator/internal/store/plugin_kanban_occurrence.go b/orchestrator/internal/store/plugin_kanban_occurrence.go new file mode 100644 index 00000000..b3ec6423 --- /dev/null +++ b/orchestrator/internal/store/plugin_kanban_occurrence.go @@ -0,0 +1,888 @@ +package store + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/cnjack/jcloud/internal/domain" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +const ( + PluginKanbanAlreadyInTrigger = "already_in_trigger" + PluginKanbanAlreadyRunning = "already_running" + PluginKanbanWritebackPending = "writeback_pending" + PluginKanbanOccurrenceActive = "occurrence_active" +) + +type PluginKanbanObservation struct { + AutomationID string + ServiceID string + InstallationID string + WorkspaceID string + DocumentID string + DocumentPath string + TriggerColumn string + DoneColumn string + ObservedColumn string + EventKey string + EventSequence *int64 + ActorDisplay string + ObservedAt time.Time +} + +type PluginKanbanObservationResult struct { + Claim domain.PluginKanbanClaim + Occurrence *domain.PluginKanbanOccurrence + Created bool + SuppressedReason string +} + +// PluginKanbanOccurrenceCursor is the decoded store-facing half of the Card +// execution API's opaque cursor. Ordering is always (created_at DESC, id DESC). +type PluginKanbanOccurrenceCursor struct { + CreatedAt time.Time + ID string +} + +func validatePluginKanbanObservation(in PluginKanbanObservation) error { + if strings.TrimSpace(in.AutomationID) == "" || strings.TrimSpace(in.ServiceID) == "" || + strings.TrimSpace(in.InstallationID) == "" || strings.TrimSpace(in.WorkspaceID) == "" || + strings.TrimSpace(in.DocumentID) == "" || strings.TrimSpace(in.DocumentPath) == "" || + strings.TrimSpace(in.TriggerColumn) == "" || strings.TrimSpace(in.EventKey) == "" { + return errors.New("invalid Kanban observation") + } + if in.ObservedAt.IsZero() { + return errors.New("Kanban observation time is required") + } + return nil +} + +func classifyPluginKanbanEntry( + claimExists bool, + claim *domain.PluginKanbanClaim, + latest *domain.PluginKanbanOccurrence, + latestRunStatus domain.RunStatus, + in PluginKanbanObservation, +) (create bool, suppressed string) { + if in.ObservedColumn != in.TriggerColumn { + return false, "" + } + if !claimExists || latest == nil { + return true, "" + } + if claim.LastObservedColumn == in.TriggerColumn { + return false, PluginKanbanAlreadyInTrigger + } + + terminal := latest.State == domain.KanbanOccurrenceTerminal || latestRunStatus.Terminal() + if !terminal { + if latest.RunID != "" { + return false, PluginKanbanAlreadyRunning + } + return false, PluginKanbanOccurrenceActive + } + if latest.WritebackState != "complete" && claim.WritebackAt == nil { + return false, PluginKanbanWritebackPending + } + if claim.OutsideTriggerAt == nil { + return false, PluginKanbanAlreadyInTrigger + } + return true, "" +} + +func newPluginKanbanClaim(in PluginKanbanObservation) domain.PluginKanbanClaim { + return domain.PluginKanbanClaim{ + AutomationID: in.AutomationID, InstallationID: in.InstallationID, + DocumentID: in.DocumentID, DocumentPath: in.DocumentPath, + WorkspaceID: in.WorkspaceID, DoneColumn: in.DoneColumn, + ExternalRefAvailable: true, CreatedAt: in.ObservedAt, UpdatedAt: in.ObservedAt, + } +} + +func newPluginKanbanOccurrence(in PluginKanbanObservation) domain.PluginKanbanOccurrence { + return domain.PluginKanbanOccurrence{ + ID: domain.NewID(), AutomationID: in.AutomationID, ServiceID: in.ServiceID, + InstallationID: in.InstallationID, WorkspaceID: in.WorkspaceID, + DocumentID: in.DocumentID, DocumentPath: in.DocumentPath, DoneColumn: in.DoneColumn, + EventKey: in.EventKey, EventSequence: in.EventSequence, ActorDisplay: in.ActorDisplay, + EntryColumn: in.TriggerColumn, State: domain.KanbanOccurrenceReceived, + WritebackState: "pending", CreatedAt: in.ObservedAt, UpdatedAt: in.ObservedAt, + } +} + +func (m *MemStore) ObservePluginKanbanCard(_ context.Context, in PluginKanbanObservation) (*PluginKanbanObservationResult, error) { + if err := validatePluginKanbanObservation(in); err != nil { + return nil, err + } + m.mu.Lock() + defer m.mu.Unlock() + + for _, occurrence := range m.pluginKanbanOccurrences { + if occurrence.AutomationID == in.AutomationID && occurrence.EventKey == in.EventKey { + claim := m.pluginKanbanClaims[pluginKanbanClaimKey(in.AutomationID, in.DocumentID)] + copy := occurrence + return &PluginKanbanObservationResult{Claim: claim, Occurrence: ©}, nil + } + } + + key := pluginKanbanClaimKey(in.AutomationID, in.DocumentID) + claim, claimExists := m.pluginKanbanClaims[key] + if !claimExists { + if _, ok := m.pluginKanbanTriggers[in.AutomationID]; !ok { + return nil, ErrNotFound + } + claim = newPluginKanbanClaim(in) + } + var latest *domain.PluginKanbanOccurrence + var latestRunStatus domain.RunStatus + if claim.LatestOccurrenceID != "" { + if value, ok := m.pluginKanbanOccurrences[claim.LatestOccurrenceID]; ok { + copy := value + latest = © + if copy.RunID != "" { + latestRunStatus = m.runs[copy.RunID].Status + } + } + } + + create, suppressed := classifyPluginKanbanEntry(claimExists, &claim, latest, latestRunStatus, in) + claim.DocumentPath = in.DocumentPath + claim.WorkspaceID = in.WorkspaceID + claim.DoneColumn = in.DoneColumn + claim.LastObservedColumn = in.ObservedColumn + claim.ExternalRefAvailable = true + claim.UpdatedAt = in.ObservedAt + if in.ObservedColumn != in.TriggerColumn { + at := in.ObservedAt + claim.OutsideTriggerAt = &at + m.pluginKanbanClaims[key] = claim + return &PluginKanbanObservationResult{Claim: claim}, nil + } + claim.OutsideTriggerAt = nil + if !create { + m.pluginKanbanClaims[key] = claim + result := &PluginKanbanObservationResult{Claim: claim, SuppressedReason: suppressed} + if suppressed == PluginKanbanAlreadyRunning || suppressed == PluginKanbanWritebackPending || suppressed == PluginKanbanOccurrenceActive { + result.Occurrence = latest + } + return result, nil + } + + occurrence := newPluginKanbanOccurrence(in) + m.pluginKanbanOccurrences[occurrence.ID] = occurrence + claim.LatestOccurrenceID = occurrence.ID + claim.RunID = "" + claim.WritebackAt = nil + m.pluginKanbanClaims[key] = claim + return &PluginKanbanObservationResult{Claim: claim, Occurrence: &occurrence, Created: true}, nil +} + +func (m *MemStore) CreatePluginKanbanOccurrenceRun(_ context.Context, occurrenceID string, run *domain.Run) (bool, error) { + if run == nil { + return false, errors.New("run is required") + } + m.mu.Lock() + defer m.mu.Unlock() + occurrence, ok := m.pluginKanbanOccurrences[occurrenceID] + if !ok { + return false, ErrNotFound + } + if occurrence.RunID != "" { + return false, nil + } + normalizeRunForCreate(run) + if err := m.validateRunForCreateLocked(run); err != nil { + return false, err + } + m.insertRunLocked(run) + now := time.Now().UTC() + occurrence.RunID = run.ID + occurrence.State = domain.KanbanOccurrenceQueued + occurrence.ReasonCode = "" + occurrence.ReasonMessage = "" + occurrence.RepairRole = "" + occurrence.ReceiptPhase = "accepted" + occurrence.ReceiptWrittenAt = nil + occurrence.WritebackState = "pending" + occurrence.WritebackError = "" + occurrence.UpdatedAt = now + m.pluginKanbanOccurrences[occurrenceID] = occurrence + key := pluginKanbanClaimKey(occurrence.AutomationID, occurrence.DocumentID) + claim := m.pluginKanbanClaims[key] + claim.RunID = run.ID + claim.WritebackAt = nil + claim.LatestOccurrenceID = occurrenceID + claim.UpdatedAt = now + m.pluginKanbanClaims[key] = claim + return true, nil +} + +func (m *MemStore) SetPluginKanbanOccurrenceBlocked(_ context.Context, occurrenceID, reasonCode, reasonMessage, repairRole string) (*domain.PluginKanbanOccurrence, error) { + m.mu.Lock() + defer m.mu.Unlock() + occurrence, ok := m.pluginKanbanOccurrences[occurrenceID] + if !ok { + return nil, ErrNotFound + } + if occurrence.RunID != "" { + return nil, ErrConflict + } + if occurrence.ReceiptPhase != "blocked" || + occurrence.ReasonCode != reasonCode || + occurrence.RepairRole != repairRole { + occurrence.ReceiptWrittenAt = nil + } + occurrence.State = domain.KanbanOccurrenceBlocked + occurrence.ReasonCode = reasonCode + occurrence.ReasonMessage = reasonMessage + occurrence.RepairRole = repairRole + occurrence.ReceiptPhase = "blocked" + occurrence.WritebackState = "not_required" + occurrence.WritebackError = "" + occurrence.UpdatedAt = time.Now().UTC() + m.pluginKanbanOccurrences[occurrenceID] = occurrence + return &occurrence, nil +} + +func sortPluginKanbanOccurrences(out []domain.PluginKanbanOccurrence) { + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].ID > out[j].ID + } + return out[i].CreatedAt.After(out[j].CreatedAt) + }) +} + +func sortPluginKanbanOccurrencesOldestFirst(out []domain.PluginKanbanOccurrence) { + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].ID < out[j].ID + } + return out[i].CreatedAt.Before(out[j].CreatedAt) + }) +} + +func (m *MemStore) ListPluginKanbanDispatchableOccurrences(_ context.Context, automationID string, limit int) ([]domain.PluginKanbanOccurrence, error) { + m.mu.Lock() + defer m.mu.Unlock() + if limit <= 0 || limit > 100 { + limit = 50 + } + out := make([]domain.PluginKanbanOccurrence, 0) + for _, occurrence := range m.pluginKanbanOccurrences { + if occurrence.AutomationID == automationID && occurrence.RunID == "" && + (occurrence.State == domain.KanbanOccurrenceReceived || occurrence.State == domain.KanbanOccurrenceBlocked) { + out = append(out, occurrence) + } + } + sortPluginKanbanOccurrencesOldestFirst(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemStore) ListPluginKanbanReceiptPending(_ context.Context, automationID string, limit int) ([]domain.PluginKanbanOccurrence, error) { + m.mu.Lock() + defer m.mu.Unlock() + if limit <= 0 || limit > 100 { + limit = 50 + } + out := make([]domain.PluginKanbanOccurrence, 0) + for _, occurrence := range m.pluginKanbanOccurrences { + if occurrence.AutomationID == automationID && + pluginKanbanReceiptPhaseRetryable(occurrence.ReceiptPhase) && + occurrence.ReceiptWrittenAt == nil { + out = append(out, occurrence) + } + } + sortPluginKanbanOccurrencesOldestFirst(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func pluginKanbanReceiptPhaseRetryable(phase string) bool { + switch phase { + case "accepted", "blocked", PluginKanbanAlreadyRunning, PluginKanbanWritebackPending: + return true + default: + return false + } +} + +func (m *MemStore) SetPluginKanbanOccurrenceReceiptPhase( + _ context.Context, + occurrenceID, phase string, +) (*domain.PluginKanbanOccurrence, error) { + if !pluginKanbanReceiptPhaseRetryable(phase) { + return nil, errors.New("invalid Kanban receipt phase") + } + m.mu.Lock() + defer m.mu.Unlock() + occurrence, ok := m.pluginKanbanOccurrences[occurrenceID] + if !ok { + return nil, ErrNotFound + } + if occurrence.ReceiptPhase != phase { + occurrence.ReceiptPhase = phase + occurrence.ReceiptWrittenAt = nil + occurrence.WritebackError = "" + occurrence.UpdatedAt = time.Now().UTC() + m.pluginKanbanOccurrences[occurrenceID] = occurrence + } + copy := occurrence + return ©, nil +} + +func (m *MemStore) MarkPluginKanbanOccurrenceReceipt(_ context.Context, occurrenceID, phase string, writtenAt *time.Time, writebackError string) error { + m.mu.Lock() + defer m.mu.Unlock() + occurrence, ok := m.pluginKanbanOccurrences[occurrenceID] + if !ok { + return ErrNotFound + } + if occurrence.ReceiptPhase != phase { + return ErrConflict + } + occurrence.WritebackError = writebackError + if writtenAt != nil { + at := *writtenAt + occurrence.ReceiptWrittenAt = &at + occurrence.WritebackError = "" + occurrence.UpdatedAt = at + } else { + occurrence.UpdatedAt = time.Now().UTC() + } + m.pluginKanbanOccurrences[occurrenceID] = occurrence + return nil +} + +func (m *MemStore) ListPluginKanbanOccurrences(_ context.Context, automationID, documentID string, limit int) ([]domain.PluginKanbanOccurrence, error) { + m.mu.Lock() + defer m.mu.Unlock() + if limit <= 0 || limit > 100 { + limit = 50 + } + out := make([]domain.PluginKanbanOccurrence, 0) + for _, occurrence := range m.pluginKanbanOccurrences { + if occurrence.AutomationID == automationID && occurrence.DocumentID == documentID { + out = append(out, occurrence) + } + } + sortPluginKanbanOccurrences(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemStore) GetPluginKanbanClaimByPath(_ context.Context, automationID, workspaceID, documentPath string) (*domain.PluginKanbanClaim, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, claim := range m.pluginKanbanClaims { + if claim.AutomationID == automationID && claim.WorkspaceID == workspaceID && + claim.DocumentPath == documentPath { + copy := claim + return ©, nil + } + } + return nil, ErrNotFound +} + +func (m *MemStore) MarkPluginKanbanCardUnavailable(_ context.Context, automationID, workspaceID, documentPath string, at time.Time) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + for key, claim := range m.pluginKanbanClaims { + if claim.AutomationID == automationID && claim.WorkspaceID == workspaceID && + claim.DocumentPath == documentPath { + claim.ExternalRefAvailable = false + claim.LastObservedColumn = "" + claim.OutsideTriggerAt = &at + claim.UpdatedAt = at + m.pluginKanbanClaims[key] = claim + return true, nil + } + } + return false, nil +} + +func pluginKanbanOccurrenceBefore(occurrence domain.PluginKanbanOccurrence, before *PluginKanbanOccurrenceCursor) bool { + if before == nil { + return true + } + return occurrence.CreatedAt.Before(before.CreatedAt) || + (occurrence.CreatedAt.Equal(before.CreatedAt) && occurrence.ID < before.ID) +} + +func (m *MemStore) ListPluginKanbanCardExecutions(_ context.Context, automationID, serviceID, workspaceID, documentPath string, before *PluginKanbanOccurrenceCursor, limit int) ([]domain.PluginKanbanOccurrence, error) { + m.mu.Lock() + defer m.mu.Unlock() + if limit <= 0 || limit > 100 { + limit = 50 + } + out := make([]domain.PluginKanbanOccurrence, 0) + for _, occurrence := range m.pluginKanbanOccurrences { + if occurrence.AutomationID == automationID && occurrence.ServiceID == serviceID && + occurrence.WorkspaceID == workspaceID && occurrence.DocumentPath == documentPath && + pluginKanbanOccurrenceBefore(occurrence, before) { + out = append(out, occurrence) + } + } + sortPluginKanbanOccurrences(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemStore) AdvancePluginKanbanTrigger(_ context.Context, automationID string, previousCursor, nextCursor int64, bootstrappedAt *time.Time) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + trigger, ok := m.pluginKanbanTriggers[automationID] + if !ok { + return false, ErrNotFound + } + if trigger.EventCursor != previousCursor || nextCursor < previousCursor { + return false, nil + } + if bootstrappedAt != nil && trigger.BootstrappedAt != nil { + return false, nil + } + trigger.EventCursor = nextCursor + if bootstrappedAt != nil { + at := *bootstrappedAt + trigger.BootstrappedAt = &at + } + m.pluginKanbanTriggers[automationID] = trigger + return true, nil +} + +const pluginKanbanOccurrenceCols = `id,automation_id,service_id,installation_id,workspace_id, + document_id,document_path,done_column,event_key,event_sequence,actor_display,entry_column, + state,outcome,reason_code,reason_message,repair_role,COALESCE(run_id,''),receipt_phase, + receipt_written_at,writeback_state,writeback_error,created_at,updated_at,terminal_at` + +const qualifiedPluginKanbanOccurrenceCols = `o.id,o.automation_id,o.service_id,o.installation_id,o.workspace_id, + o.document_id,o.document_path,o.done_column,o.event_key,o.event_sequence,o.actor_display,o.entry_column, + o.state,o.outcome,o.reason_code,o.reason_message,o.repair_role,COALESCE(o.run_id,''),o.receipt_phase, + o.receipt_written_at,o.writeback_state,o.writeback_error,o.created_at,o.updated_at,o.terminal_at` + +const pluginKanbanClaimCols = `automation_id,installation_id,document_id,document_path, + workspace_id,done_column,COALESCE(run_id,''),writeback_at,last_observed_column, + outside_trigger_at,latest_occurrence_id,external_ref_available,created_at,updated_at` + +func scanPluginKanbanClaim(row pgx.Row) (*domain.PluginKanbanClaim, error) { + var claim domain.PluginKanbanClaim + err := row.Scan( + &claim.AutomationID, &claim.InstallationID, &claim.DocumentID, + &claim.DocumentPath, &claim.WorkspaceID, &claim.DoneColumn, &claim.RunID, + &claim.WritebackAt, &claim.LastObservedColumn, &claim.OutsideTriggerAt, + &claim.LatestOccurrenceID, &claim.ExternalRefAvailable, &claim.CreatedAt, + &claim.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("scan Kanban claim: %w", err) + } + return &claim, nil +} + +func scanPluginKanbanOccurrence(row pgx.Row) (*domain.PluginKanbanOccurrence, error) { + var occurrence domain.PluginKanbanOccurrence + err := row.Scan( + &occurrence.ID, &occurrence.AutomationID, &occurrence.ServiceID, + &occurrence.InstallationID, &occurrence.WorkspaceID, &occurrence.DocumentID, + &occurrence.DocumentPath, &occurrence.DoneColumn, &occurrence.EventKey, + &occurrence.EventSequence, &occurrence.ActorDisplay, &occurrence.EntryColumn, + &occurrence.State, &occurrence.Outcome, &occurrence.ReasonCode, + &occurrence.ReasonMessage, &occurrence.RepairRole, &occurrence.RunID, + &occurrence.ReceiptPhase, &occurrence.ReceiptWrittenAt, &occurrence.WritebackState, + &occurrence.WritebackError, &occurrence.CreatedAt, &occurrence.UpdatedAt, + &occurrence.TerminalAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("scan Kanban occurrence: %w", err) + } + return &occurrence, nil +} + +func (s *PGStore) ObservePluginKanbanCard(ctx context.Context, in PluginKanbanObservation) (*PluginKanbanObservationResult, error) { + if err := validatePluginKanbanObservation(in); err != nil { + return nil, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("observe Kanban card: begin: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + existing, err := scanPluginKanbanOccurrence(tx.QueryRow(ctx, + `SELECT `+pluginKanbanOccurrenceCols+` FROM automation_kanban_occurrences WHERE automation_id=$1 AND event_key=$2`, + in.AutomationID, in.EventKey)) + if err == nil { + claim, claimErr := scanPluginKanbanClaim(tx.QueryRow(ctx, + `SELECT `+pluginKanbanClaimCols+` FROM automation_kanban_claims WHERE automation_id=$1 AND document_id=$2`, + in.AutomationID, in.DocumentID)) + if claimErr != nil { + return nil, claimErr + } + return &PluginKanbanObservationResult{Claim: *claim, Occurrence: existing}, nil + } + if !errors.Is(err, ErrNotFound) { + return nil, err + } + + claim, claimErr := scanPluginKanbanClaim(tx.QueryRow(ctx, + `SELECT `+pluginKanbanClaimCols+` FROM automation_kanban_claims WHERE automation_id=$1 AND document_id=$2 FOR UPDATE`, + in.AutomationID, in.DocumentID)) + claimExists := claimErr == nil + if claimErr != nil && !errors.Is(claimErr, ErrNotFound) { + return nil, claimErr + } + if !claimExists { + value := newPluginKanbanClaim(in) + if _, err = tx.Exec(ctx, `INSERT INTO automation_kanban_claims( + automation_id,installation_id,document_id,document_path,workspace_id,done_column, + last_observed_column,external_ref_available,created_at,updated_at + ) VALUES($1,$2,$3,$4,$5,$6,'',TRUE,$7,$7)`, + in.AutomationID, in.InstallationID, in.DocumentID, in.DocumentPath, + in.WorkspaceID, in.DoneColumn, in.ObservedAt); err != nil { + if isUniqueViolation(err) { + _ = tx.Rollback(ctx) + return s.ObservePluginKanbanCard(ctx, in) + } + return nil, fmt.Errorf("observe Kanban card: create claim: %w", err) + } + claim = &value + } + + var latest *domain.PluginKanbanOccurrence + var latestRunStatus domain.RunStatus + if claim.LatestOccurrenceID != "" { + latest, err = scanPluginKanbanOccurrence(tx.QueryRow(ctx, + `SELECT `+pluginKanbanOccurrenceCols+` FROM automation_kanban_occurrences WHERE id=$1`, + claim.LatestOccurrenceID)) + if err != nil && !errors.Is(err, ErrNotFound) { + return nil, err + } + if latest != nil && latest.RunID != "" { + if err = tx.QueryRow(ctx, `SELECT status FROM runs WHERE id=$1`, latest.RunID).Scan(&latestRunStatus); errors.Is(err, pgx.ErrNoRows) { + err = nil + } + if err != nil { + return nil, fmt.Errorf("observe Kanban card: load latest run: %w", err) + } + } + } + + create, suppressed := classifyPluginKanbanEntry(claimExists, claim, latest, latestRunStatus, in) + var outsideAt any + if in.ObservedColumn != in.TriggerColumn { + outsideAt = in.ObservedAt + } + _, err = tx.Exec(ctx, `UPDATE automation_kanban_claims SET + document_path=$3,workspace_id=$4,done_column=$5,last_observed_column=$6, + outside_trigger_at=$7,external_ref_available=TRUE,updated_at=$8 + WHERE automation_id=$1 AND document_id=$2`, + in.AutomationID, in.DocumentID, in.DocumentPath, in.WorkspaceID, + in.DoneColumn, in.ObservedColumn, outsideAt, in.ObservedAt) + if err != nil { + return nil, fmt.Errorf("observe Kanban card: update claim: %w", err) + } + claim.DocumentPath = in.DocumentPath + claim.WorkspaceID = in.WorkspaceID + claim.DoneColumn = in.DoneColumn + claim.LastObservedColumn = in.ObservedColumn + claim.ExternalRefAvailable = true + claim.UpdatedAt = in.ObservedAt + if outsideAt != nil { + at := in.ObservedAt + claim.OutsideTriggerAt = &at + } else { + claim.OutsideTriggerAt = nil + } + if !create { + if err = tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("observe Kanban card: commit observation: %w", err) + } + result := &PluginKanbanObservationResult{Claim: *claim, SuppressedReason: suppressed} + if suppressed == PluginKanbanAlreadyRunning || suppressed == PluginKanbanWritebackPending || suppressed == PluginKanbanOccurrenceActive { + result.Occurrence = latest + } + return result, nil + } + + occurrence := newPluginKanbanOccurrence(in) + _, err = tx.Exec(ctx, `INSERT INTO automation_kanban_occurrences( + id,automation_id,service_id,installation_id,workspace_id,document_id,document_path, + done_column,event_key,event_sequence,actor_display,entry_column,state,writeback_state, + created_at,updated_at + ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15)`, + occurrence.ID, occurrence.AutomationID, occurrence.ServiceID, + occurrence.InstallationID, occurrence.WorkspaceID, occurrence.DocumentID, + occurrence.DocumentPath, occurrence.DoneColumn, occurrence.EventKey, + occurrence.EventSequence, occurrence.ActorDisplay, occurrence.EntryColumn, + occurrence.State, occurrence.WritebackState, occurrence.CreatedAt) + if err != nil { + if isUniqueViolation(err) { + _ = tx.Rollback(ctx) + return s.ObservePluginKanbanCard(ctx, in) + } + return nil, fmt.Errorf("observe Kanban card: create occurrence: %w", err) + } + _, err = tx.Exec(ctx, `UPDATE automation_kanban_claims + SET latest_occurrence_id=$3,run_id=NULL,writeback_at=NULL,updated_at=$4 + WHERE automation_id=$1 AND document_id=$2`, + in.AutomationID, in.DocumentID, occurrence.ID, in.ObservedAt) + if err != nil { + return nil, fmt.Errorf("observe Kanban card: attach occurrence: %w", err) + } + claim.LatestOccurrenceID = occurrence.ID + claim.RunID = "" + claim.WritebackAt = nil + if err = tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("observe Kanban card: commit occurrence: %w", err) + } + return &PluginKanbanObservationResult{Claim: *claim, Occurrence: &occurrence, Created: true}, nil +} + +func (s *PGStore) CreatePluginKanbanOccurrenceRun(ctx context.Context, occurrenceID string, run *domain.Run) (bool, error) { + if run == nil { + return false, errors.New("run is required") + } + normalizeRunForCreate(run) + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("claim Kanban occurrence run: begin: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + occurrence, err := scanPluginKanbanOccurrence(tx.QueryRow(ctx, + `SELECT `+pluginKanbanOccurrenceCols+` FROM automation_kanban_occurrences WHERE id=$1 FOR UPDATE`, + occurrenceID)) + if err != nil { + return false, err + } + if occurrence.RunID != "" { + return false, nil + } + if err = s.createRunTx(ctx, tx, run); err != nil { + return false, err + } + now := time.Now().UTC() + if _, err = tx.Exec(ctx, `UPDATE automation_kanban_occurrences SET + run_id=$2,state='queued',reason_code='',reason_message='',repair_role='', + receipt_phase='accepted',receipt_written_at=NULL,writeback_state='pending', + writeback_error='',updated_at=$3 + WHERE id=$1`, occurrenceID, run.ID, now); err != nil { + return false, fmt.Errorf("claim Kanban occurrence run: update occurrence: %w", err) + } + if _, err = tx.Exec(ctx, `UPDATE automation_kanban_claims SET + run_id=$3,writeback_at=NULL,latest_occurrence_id=$4,updated_at=$5 + WHERE automation_id=$1 AND document_id=$2`, + occurrence.AutomationID, occurrence.DocumentID, run.ID, occurrenceID, now); err != nil { + return false, fmt.Errorf("claim Kanban occurrence run: update claim: %w", err) + } + if err = tx.Commit(ctx); err != nil { + return false, fmt.Errorf("claim Kanban occurrence run: commit: %w", err) + } + return true, nil +} + +func (s *PGStore) SetPluginKanbanOccurrenceBlocked(ctx context.Context, occurrenceID, reasonCode, reasonMessage, repairRole string) (*domain.PluginKanbanOccurrence, error) { + tag, err := s.pool.Exec(ctx, `UPDATE automation_kanban_occurrences SET + state='blocked',reason_code=$2,reason_message=$3,repair_role=$4, + receipt_written_at=CASE + WHEN receipt_phase='blocked' AND reason_code=$2 AND repair_role=$4 + THEN receipt_written_at ELSE NULL END, + receipt_phase='blocked',writeback_state='not_required',writeback_error='',updated_at=now() + WHERE id=$1 AND run_id IS NULL`, + occurrenceID, reasonCode, reasonMessage, repairRole) + if err != nil { + return nil, fmt.Errorf("block Kanban occurrence: %w", err) + } + if tag.RowsAffected() == 0 { + if _, loadErr := scanPluginKanbanOccurrence(s.pool.QueryRow(ctx, + `SELECT `+pluginKanbanOccurrenceCols+` FROM automation_kanban_occurrences WHERE id=$1`, + occurrenceID)); errors.Is(loadErr, ErrNotFound) { + return nil, ErrNotFound + } + return nil, ErrConflict + } + return scanPluginKanbanOccurrence(s.pool.QueryRow(ctx, + `SELECT `+pluginKanbanOccurrenceCols+` FROM automation_kanban_occurrences WHERE id=$1`, + occurrenceID)) +} + +func (s *PGStore) ListPluginKanbanDispatchableOccurrences(ctx context.Context, automationID string, limit int) ([]domain.PluginKanbanOccurrence, error) { + return s.listPluginKanbanOccurrencesByPredicate(ctx, automationID, + `run_id IS NULL AND state IN ('received','blocked')`, limit) +} + +func (s *PGStore) ListPluginKanbanReceiptPending(ctx context.Context, automationID string, limit int) ([]domain.PluginKanbanOccurrence, error) { + return s.listPluginKanbanOccurrencesByPredicate(ctx, automationID, + `receipt_phase IN ('accepted','blocked','already_running','writeback_pending') + AND receipt_written_at IS NULL`, limit) +} + +func (s *PGStore) listPluginKanbanOccurrencesByPredicate(ctx context.Context, automationID, predicate string, limit int) ([]domain.PluginKanbanOccurrence, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.pool.Query(ctx, `SELECT `+pluginKanbanOccurrenceCols+` + FROM automation_kanban_occurrences + WHERE automation_id=$1 AND `+predicate+` + ORDER BY created_at,id LIMIT $2`, automationID, limit) + if err != nil { + return nil, fmt.Errorf("list actionable Kanban occurrences: %w", err) + } + defer rows.Close() + out := make([]domain.PluginKanbanOccurrence, 0) + for rows.Next() { + occurrence, scanErr := scanPluginKanbanOccurrence(rows) + if scanErr != nil { + return nil, scanErr + } + out = append(out, *occurrence) + } + return out, rows.Err() +} + +func (s *PGStore) SetPluginKanbanOccurrenceReceiptPhase( + ctx context.Context, + occurrenceID, phase string, +) (*domain.PluginKanbanOccurrence, error) { + if !pluginKanbanReceiptPhaseRetryable(phase) { + return nil, errors.New("invalid Kanban receipt phase") + } + return scanPluginKanbanOccurrence(s.pool.QueryRow(ctx, `UPDATE automation_kanban_occurrences + SET receipt_phase=$2, + receipt_written_at=CASE WHEN receipt_phase=$2 THEN receipt_written_at ELSE NULL END, + writeback_error=CASE WHEN receipt_phase=$2 THEN writeback_error ELSE '' END, + updated_at=CASE WHEN receipt_phase=$2 THEN updated_at ELSE now() END + WHERE id=$1 + RETURNING `+pluginKanbanOccurrenceCols, + occurrenceID, phase)) +} + +func (s *PGStore) MarkPluginKanbanOccurrenceReceipt(ctx context.Context, occurrenceID, phase string, writtenAt *time.Time, writebackError string) error { + tag, err := s.pool.Exec(ctx, `UPDATE automation_kanban_occurrences SET + receipt_written_at=$3,writeback_error=$4,updated_at=now() + WHERE id=$1 AND receipt_phase=$2`, + occurrenceID, phase, writtenAt, writebackError) + if err != nil { + return fmt.Errorf("mark Kanban occurrence receipt: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrConflict + } + return nil +} + +func (s *PGStore) ListPluginKanbanOccurrences(ctx context.Context, automationID, documentID string, limit int) ([]domain.PluginKanbanOccurrence, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.pool.Query(ctx, `SELECT `+pluginKanbanOccurrenceCols+` + FROM automation_kanban_occurrences + WHERE automation_id=$1 AND document_id=$2 + ORDER BY created_at DESC,id DESC LIMIT $3`, automationID, documentID, limit) + if err != nil { + return nil, fmt.Errorf("list Kanban occurrences: %w", err) + } + defer rows.Close() + out := make([]domain.PluginKanbanOccurrence, 0) + for rows.Next() { + occurrence, err := scanPluginKanbanOccurrence(rows) + if err != nil { + return nil, err + } + out = append(out, *occurrence) + } + return out, rows.Err() +} + +func (s *PGStore) GetPluginKanbanClaimByPath(ctx context.Context, automationID, workspaceID, documentPath string) (*domain.PluginKanbanClaim, error) { + return scanPluginKanbanClaim(s.pool.QueryRow(ctx, `SELECT `+pluginKanbanClaimCols+` + FROM automation_kanban_claims + WHERE automation_id=$1 AND workspace_id=$2 AND document_path=$3`, + automationID, workspaceID, documentPath)) +} + +func (s *PGStore) MarkPluginKanbanCardUnavailable(ctx context.Context, automationID, workspaceID, documentPath string, at time.Time) (bool, error) { + tag, err := s.pool.Exec(ctx, `UPDATE automation_kanban_claims + SET external_ref_available=FALSE,last_observed_column='', + outside_trigger_at=$4,updated_at=$4 + WHERE automation_id=$1 AND workspace_id=$2 AND document_path=$3`, + automationID, workspaceID, documentPath, at) + if err != nil { + return false, fmt.Errorf("mark Kanban Card unavailable: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *PGStore) ListPluginKanbanCardExecutions(ctx context.Context, automationID, serviceID, workspaceID, documentPath string, before *PluginKanbanOccurrenceCursor, limit int) ([]domain.PluginKanbanOccurrence, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + var beforeAt any + var beforeID any + if before != nil { + beforeAt = before.CreatedAt + beforeID = before.ID + } + rows, err := s.pool.Query(ctx, `SELECT `+pluginKanbanOccurrenceCols+` + FROM automation_kanban_occurrences + WHERE automation_id=$1 AND service_id=$2 AND workspace_id=$3 AND document_path=$4 + AND ($5::timestamptz IS NULL OR (created_at,id) < ($5,$6)) + ORDER BY created_at DESC,id DESC LIMIT $7`, + automationID, serviceID, workspaceID, documentPath, beforeAt, beforeID, limit) + if err != nil { + return nil, fmt.Errorf("list Card executions: %w", err) + } + defer rows.Close() + out := make([]domain.PluginKanbanOccurrence, 0) + for rows.Next() { + occurrence, scanErr := scanPluginKanbanOccurrence(rows) + if scanErr != nil { + return nil, scanErr + } + out = append(out, *occurrence) + } + return out, rows.Err() +} + +func (s *PGStore) AdvancePluginKanbanTrigger(ctx context.Context, automationID string, previousCursor, nextCursor int64, bootstrappedAt *time.Time) (bool, error) { + if nextCursor < previousCursor { + return false, nil + } + var tag pgconn.CommandTag + var err error + if bootstrappedAt != nil { + tag, err = s.pool.Exec(ctx, `UPDATE automation_kanban_triggers + SET event_cursor=$3,bootstrapped_at=$4 + WHERE automation_id=$1 AND event_cursor=$2 AND bootstrapped_at IS NULL`, + automationID, previousCursor, nextCursor, bootstrappedAt) + } else { + tag, err = s.pool.Exec(ctx, `UPDATE automation_kanban_triggers + SET event_cursor=$3 + WHERE automation_id=$1 AND event_cursor=$2`, + automationID, previousCursor, nextCursor) + } + if err != nil { + return false, fmt.Errorf("advance Kanban trigger cursor: %w", err) + } + return tag.RowsAffected() == 1, nil +} diff --git a/orchestrator/internal/store/plugin_kanban_occurrence_test.go b/orchestrator/internal/store/plugin_kanban_occurrence_test.go new file mode 100644 index 00000000..3e901bde --- /dev/null +++ b/orchestrator/internal/store/plugin_kanban_occurrence_test.go @@ -0,0 +1,537 @@ +package store + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/cnjack/jcloud/internal/domain" +) + +func seedPluginKanbanOccurrenceStore(t *testing.T) (*MemStore, *domain.PluginAutomation, *domain.KanbanTrigger) { + t.Helper() + ctx := context.Background() + st := NewMemStore() + if err := st.CreateProject(ctx, &domain.Project{ID: "project", Name: "Project"}); err != nil { + t.Fatal(err) + } + if err := st.CreateService(ctx, &domain.Service{ + ID: "service", ProjectID: "project", Name: "payments", RepoKind: domain.RepoKindRaw, + }); err != nil { + t.Fatal(err) + } + installation := &domain.PluginInstallation{ + ID: "jtype", ProjectID: "project", Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "workspace", + } + if err := st.CreatePluginInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + automation := &domain.PluginAutomation{ + ID: "automation", ServiceID: "service", InstallationID: installation.ID, + Name: "Agent queue", TriggerKind: "kanban", RunKind: domain.RunKindAgent, + Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "delivery", TriggerColumn: "agent", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + return st, automation, trigger +} + +func TestPGPluginKanbanOccurrenceRunClaimConcurrent(t *testing.T) { + ctx := context.Background() + st, seedRunID := pgTestStore(t) + seedRun, err := st.GetRun(ctx, seedRunID) + if err != nil { + t.Fatal(err) + } + installation := &domain.PluginInstallation{ + ID: domain.NewID(), ProjectID: seedRun.ProjectID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "workspace", Scopes: []string{}, + } + if err := st.CreatePluginInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + automation := &domain.PluginAutomation{ + ID: domain.NewID(), ServiceID: seedRun.ServiceID, InstallationID: installation.ID, + Name: "Agent queue", TriggerKind: "kanban", RunKind: domain.RunKindAgent, Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "delivery", TriggerColumn: "agent", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + result, err := st.ObservePluginKanbanCard(ctx, PluginKanbanObservation{ + AutomationID: automation.ID, ServiceID: automation.ServiceID, + InstallationID: installation.ID, WorkspaceID: installation.WorkspaceID, + DocumentID: "card", DocumentPath: "cards/card.md", + TriggerColumn: "agent", DoneColumn: "done", ObservedColumn: "agent", + EventKey: "event:1", EventSequence: int64PtrStore(1), ObservedAt: time.Now().UTC(), + }) + if err != nil || result.Occurrence == nil { + t.Fatalf("observe = %+v, %v", result, err) + } + + const concurrent = 12 + start := make(chan struct{}) + results := make(chan bool, concurrent) + errs := make(chan error, concurrent) + var wg sync.WaitGroup + for i := 0; i < concurrent; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + run := &domain.Run{ + ID: domain.NewID(), ProjectID: seedRun.ProjectID, ServiceID: seedRun.ServiceID, + Status: domain.StatusQueued, Origin: domain.RunOriginKanban, + OriginAutomationID: automation.ID, OriginEventKey: result.Occurrence.ID, + CreatedAt: time.Now().UTC(), + } + attached, claimErr := st.CreatePluginKanbanOccurrenceRun(ctx, result.Occurrence.ID, run) + results <- attached + errs <- claimErr + }() + } + close(start) + wg.Wait() + close(results) + close(errs) + attachedCount := 0 + for attached := range results { + if attached { + attachedCount++ + } + } + for claimErr := range errs { + if claimErr != nil { + t.Fatalf("concurrent claim: %v", claimErr) + } + } + if attachedCount != 1 { + t.Fatalf("attached count = %d, want 1", attachedCount) + } + history, err := st.ListPluginKanbanOccurrences(ctx, automation.ID, "card", 10) + if err != nil || len(history) != 1 || history[0].RunID == "" { + t.Fatalf("history = %+v, %v", history, err) + } + page, err := st.ListPluginKanbanCardExecutions( + ctx, automation.ID, automation.ServiceID, installation.WorkspaceID, + "cards/card.md", nil, 10, + ) + if err != nil || len(page) != 1 || page[0].ID != result.Occurrence.ID { + t.Fatalf("Card page=%+v err=%v", page, err) + } + runID := history[0].RunID + if _, err := st.ScheduleRun(ctx, runID, "job", "token", "Scheduling"); err != nil { + t.Fatal(err) + } + if _, err := st.MarkRunning(ctx, runID, "Running", time.Now().UTC()); err != nil { + t.Fatal(err) + } + if _, err := st.MarkSucceeded(ctx, runID, "Succeeded", time.Now().UTC()); err != nil { + t.Fatal(err) + } + if marked, err := st.MarkPluginKanbanWritebackUnavailable( + ctx, automation.ID, "card", result.Occurrence.ID, + domain.StatusSucceeded, nil, + "Card was deleted.", time.Now().UTC(), + ); err != nil || !marked { + t.Fatalf("mark unavailable=%v err=%v", marked, err) + } + claim, err := st.GetPluginKanbanClaimByPath( + ctx, automation.ID, installation.WorkspaceID, "cards/card.md", + ) + if err != nil || claim.ExternalRefAvailable { + t.Fatalf("claim=%+v err=%v", claim, err) + } + history, err = st.ListPluginKanbanOccurrences(ctx, automation.ID, "card", 10) + if err != nil || history[0].WritebackState != "unavailable" || + history[0].Outcome != string(domain.StatusSucceeded) { + t.Fatalf("unavailable history=%+v err=%v", history, err) + } + restored, err := st.ObservePluginKanbanCard(ctx, PluginKanbanObservation{ + AutomationID: automation.ID, ServiceID: automation.ServiceID, + InstallationID: installation.ID, WorkspaceID: installation.WorkspaceID, + DocumentID: "card", DocumentPath: "cards/card.md", + TriggerColumn: "agent", DoneColumn: "done", ObservedColumn: "agent", + EventKey: "event:restored", EventSequence: int64PtrStore(2), + ObservedAt: time.Now().UTC(), + }) + if err != nil || !restored.Created || restored.Occurrence == nil || + restored.Occurrence.ID == result.Occurrence.ID { + t.Fatalf("restored Card occurrence=%+v err=%v", restored, err) + } +} + +func int64PtrStore(value int64) *int64 { return &value } + +func observePluginCard( + t *testing.T, + st *MemStore, + automation *domain.PluginAutomation, + trigger *domain.KanbanTrigger, + eventKey, column string, + sequence *int64, +) *PluginKanbanObservationResult { + t.Helper() + result, err := st.ObservePluginKanbanCard(context.Background(), PluginKanbanObservation{ + AutomationID: automation.ID, + ServiceID: automation.ServiceID, + InstallationID: trigger.InstallationID, + WorkspaceID: "workspace", + DocumentID: "card", + DocumentPath: "cards/payment.md", + TriggerColumn: trigger.TriggerColumn, + DoneColumn: trigger.DoneColumn, + ObservedColumn: column, + EventKey: eventKey, + EventSequence: sequence, + ActorDisplay: "External editor", + ObservedAt: time.Now().UTC(), + }) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestPluginKanbanOccurrenceLifecycle(t *testing.T) { + st, automation, trigger := seedPluginKanbanOccurrenceStore(t) + + first := observePluginCard(t, st, automation, trigger, "bootstrap:automation:card", "agent", nil) + if !first.Created || first.Occurrence == nil { + t.Fatalf("bootstrap result = %+v, want one occurrence", first) + } + if first.Occurrence.State != domain.KanbanOccurrenceReceived { + t.Fatalf("bootstrap state = %q, want received", first.Occurrence.State) + } + + replay := observePluginCard(t, st, automation, trigger, "bootstrap:automation:card", "agent", nil) + if replay.Created || replay.Occurrence == nil || replay.Occurrence.ID != first.Occurrence.ID { + t.Fatalf("replay result = %+v, want existing occurrence %q", replay, first.Occurrence.ID) + } + + editSequence := int64(2) + edit := observePluginCard(t, st, automation, trigger, "event:2", "agent", &editSequence) + if edit.Created || edit.Occurrence != nil || edit.SuppressedReason != PluginKanbanAlreadyInTrigger { + t.Fatalf("in-column edit result = %+v", edit) + } + + run := &domain.Run{ + ID: "run-1", ProjectID: "project", ServiceID: "service", Status: domain.StatusQueued, + Origin: domain.RunOriginKanban, OriginAutomationID: automation.ID, + OriginEventKey: first.Occurrence.ID, CreatedAt: time.Now().UTC(), + } + attached, err := st.CreatePluginKanbanOccurrenceRun(context.Background(), first.Occurrence.ID, run) + if err != nil || !attached { + t.Fatalf("attach first run = %v, %v", attached, err) + } + + leaveSequence := int64(3) + leave := observePluginCard(t, st, automation, trigger, "event:3", "", &leaveSequence) + if leave.Created || leave.Occurrence != nil { + t.Fatalf("leave result = %+v, want observation only", leave) + } + + reenterSequence := int64(4) + active := observePluginCard(t, st, automation, trigger, "event:4", "agent", &reenterSequence) + if active.Created || active.SuppressedReason != PluginKanbanAlreadyRunning { + t.Fatalf("active re-entry result = %+v", active) + } + + if _, err := st.ScheduleRun(context.Background(), run.ID, "job", "token", "Scheduling"); err != nil { + t.Fatal(err) + } + if _, err := st.MarkRunning(context.Background(), run.ID, "Running", time.Now().UTC()); err != nil { + t.Fatal(err) + } + if _, err := st.MarkSucceeded(context.Background(), run.ID, "Succeeded", time.Now().UTC()); err != nil { + t.Fatal(err) + } + + pendingLeaveSequence := int64(5) + observePluginCard(t, st, automation, trigger, "event:5", "todo", &pendingLeaveSequence) + pendingSequence := int64(6) + pending := observePluginCard(t, st, automation, trigger, "event:6", "agent", &pendingSequence) + if pending.Created || pending.SuppressedReason != PluginKanbanWritebackPending { + t.Fatalf("writeback-pending re-entry result = %+v", pending) + } + + if wrote, err := st.MarkPluginKanbanWriteback( + context.Background(), automation.ID, "card", first.Occurrence.ID, + domain.StatusSucceeded, nil, time.Now().UTC(), + ); err != nil || !wrote { + t.Fatalf("mark writeback = %v, %v", wrote, err) + } + secondLeaveSequence := int64(7) + observePluginCard(t, st, automation, trigger, "event:7", "todo", &secondLeaveSequence) + secondEntrySequence := int64(8) + second := observePluginCard(t, st, automation, trigger, "event:8", "agent", &secondEntrySequence) + if !second.Created || second.Occurrence == nil || second.Occurrence.ID == first.Occurrence.ID { + t.Fatalf("second entry result = %+v, want a new occurrence", second) + } + + history, err := st.ListPluginKanbanOccurrences(context.Background(), automation.ID, "card", 10) + if err != nil || len(history) != 2 { + t.Fatalf("history = %+v, %v; want two occurrences", history, err) + } +} + +func TestPluginKanbanStaleWritebackCannotCompleteNewOccurrence(t *testing.T) { + ctx := context.Background() + st, automation, trigger := seedPluginKanbanOccurrenceStore(t) + first := observePluginCard(t, st, automation, trigger, "event:1", "agent", int64PtrStore(1)) + run := &domain.Run{ + ID: "run-stale-writeback", ProjectID: "project", ServiceID: "service", + Status: domain.StatusQueued, Origin: domain.RunOriginKanban, + OriginAutomationID: automation.ID, OriginEventKey: first.Occurrence.ID, + CreatedAt: time.Now().UTC(), + } + if attached, err := st.CreatePluginKanbanOccurrenceRun(ctx, first.Occurrence.ID, run); err != nil || !attached { + t.Fatalf("attach first run=%v err=%v", attached, err) + } + if _, err := st.ScheduleRun(ctx, run.ID, "job", "token", "Scheduling"); err != nil { + t.Fatal(err) + } + if _, err := st.MarkSucceeded(ctx, run.ID, "Succeeded", time.Now().UTC()); err != nil { + t.Fatal(err) + } + pending, err := st.ListPluginKanbanRunsAwaitingWriteback(ctx) + if err != nil || len(pending) != 1 || pending[0].Occurrence == nil { + t.Fatalf("pending=%+v err=%v", pending, err) + } + staleOccurrenceID := pending[0].Occurrence.ID + if wrote, err := st.MarkPluginKanbanWriteback( + ctx, automation.ID, "card", staleOccurrenceID, + domain.StatusSucceeded, nil, time.Now().UTC(), + ); err != nil || !wrote { + t.Fatalf("first writeback=%v err=%v", wrote, err) + } + + observePluginCard(t, st, automation, trigger, "event:2", "todo", int64PtrStore(2)) + second := observePluginCard(t, st, automation, trigger, "event:3", "agent", int64PtrStore(3)) + if !second.Created || second.Occurrence == nil { + t.Fatalf("second occurrence=%+v", second) + } + if wrote, err := st.MarkPluginKanbanWriteback( + ctx, automation.ID, "card", staleOccurrenceID, + domain.StatusSucceeded, nil, time.Now().UTC(), + ); err != nil || wrote { + t.Fatalf("stale writeback=%v err=%v, want false,nil", wrote, err) + } + claim, err := st.GetPluginKanbanClaimByPath( + ctx, automation.ID, "workspace", "cards/payment.md", + ) + if err != nil || claim.WritebackAt != nil || claim.LatestOccurrenceID != second.Occurrence.ID { + t.Fatalf("new claim was corrupted by stale writeback: %+v err=%v", claim, err) + } + history, err := st.ListPluginKanbanOccurrences(ctx, automation.ID, "card", 10) + if err != nil || history[0].ID != second.Occurrence.ID || + history[0].State != domain.KanbanOccurrenceReceived { + t.Fatalf("new occurrence was corrupted by stale writeback: %+v err=%v", history, err) + } +} + +func TestPluginKanbanOccurrenceRunClaimIsAtomic(t *testing.T) { + st, automation, trigger := seedPluginKanbanOccurrenceStore(t) + first := observePluginCard(t, st, automation, trigger, "bootstrap:automation:card", "agent", nil) + + run1 := &domain.Run{ + ID: "run-1", ProjectID: "project", ServiceID: "service", Status: domain.StatusQueued, + Origin: domain.RunOriginKanban, OriginAutomationID: automation.ID, + OriginEventKey: first.Occurrence.ID, CreatedAt: time.Now().UTC(), + } + run2 := *run1 + run2.ID = "run-2" + + attached, err := st.CreatePluginKanbanOccurrenceRun(context.Background(), first.Occurrence.ID, run1) + if err != nil || !attached { + t.Fatalf("first claim = %v, %v", attached, err) + } + attached, err = st.CreatePluginKanbanOccurrenceRun(context.Background(), first.Occurrence.ID, &run2) + if err != nil || attached { + t.Fatalf("second claim = %v, %v; want a harmless no-op", attached, err) + } + if _, err := st.GetRun(context.Background(), run2.ID); err != ErrNotFound { + t.Fatalf("losing run was persisted: %v", err) + } +} + +func TestPluginKanbanCardExecutionClaimAvailabilityAndCursor(t *testing.T) { + st, automation, trigger := seedPluginKanbanOccurrenceStore(t) + ctx := context.Background() + base := time.Date(2026, 7, 31, 2, 0, 0, 0, time.UTC) + + st.mu.Lock() + st.pluginKanbanClaims[pluginKanbanClaimKey(automation.ID, "card")] = domain.PluginKanbanClaim{ + AutomationID: automation.ID, InstallationID: trigger.InstallationID, + DocumentID: "card", DocumentPath: "cards/payment.md", WorkspaceID: "workspace", + ExternalRefAvailable: true, CreatedAt: base, UpdatedAt: base, + } + for index, id := range []string{"occ-a", "occ-b", "occ-c"} { + createdAt := base.Add(time.Duration(index) * time.Minute) + st.pluginKanbanOccurrences[id] = domain.PluginKanbanOccurrence{ + ID: id, AutomationID: automation.ID, ServiceID: automation.ServiceID, + InstallationID: trigger.InstallationID, WorkspaceID: "workspace", + DocumentID: "card", DocumentPath: "cards/payment.md", EventKey: id, + EntryColumn: trigger.TriggerColumn, State: domain.KanbanOccurrenceTerminal, + WritebackState: "complete", CreatedAt: createdAt, UpdatedAt: createdAt, + } + } + st.mu.Unlock() + + page, err := st.ListPluginKanbanCardExecutions( + ctx, automation.ID, automation.ServiceID, "workspace", "cards/payment.md", nil, 2, + ) + if err != nil || len(page) != 2 || page[0].ID != "occ-c" || page[1].ID != "occ-b" { + t.Fatalf("first page=%+v err=%v", page, err) + } + before := &PluginKanbanOccurrenceCursor{CreatedAt: page[1].CreatedAt, ID: page[1].ID} + page, err = st.ListPluginKanbanCardExecutions( + ctx, automation.ID, automation.ServiceID, "workspace", "cards/payment.md", before, 2, + ) + if err != nil || len(page) != 1 || page[0].ID != "occ-a" { + t.Fatalf("second page=%+v err=%v", page, err) + } + marked, err := st.MarkPluginKanbanCardUnavailable( + ctx, automation.ID, "workspace", "cards/payment.md", base.Add(5*time.Minute), + ) + if err != nil || !marked { + t.Fatalf("mark unavailable=%v err=%v", marked, err) + } + claim, err := st.GetPluginKanbanClaimByPath( + ctx, automation.ID, "workspace", "cards/payment.md", + ) + if err != nil || claim.ExternalRefAvailable { + t.Fatalf("claim=%+v err=%v; want unavailable", claim, err) + } +} + +func TestPluginKanbanHistorySurvivesServiceAndRunDeletion(t *testing.T) { + st, automation, trigger := seedPluginKanbanOccurrenceStore(t) + ctx := context.Background() + first := observePluginCard( + t, st, automation, trigger, "event:1", trigger.TriggerColumn, int64PtrStore(1), + ) + run := &domain.Run{ + ID: "deleting-run", ProjectID: "project", ServiceID: automation.ServiceID, + Status: domain.StatusQueued, Origin: domain.RunOriginKanban, + OriginAutomationID: automation.ID, OriginEventKey: first.Occurrence.ID, + CreatedAt: time.Now().UTC(), + } + if attached, err := st.CreatePluginKanbanOccurrenceRun(ctx, first.Occurrence.ID, run); err != nil || !attached { + t.Fatalf("attach=%v err=%v", attached, err) + } + if err := st.DeleteService(ctx, automation.ServiceID); err != nil { + t.Fatal(err) + } + history, err := st.ListPluginKanbanOccurrences(ctx, automation.ID, "card", 10) + if err != nil || len(history) != 1 || history[0].RunID != "" { + t.Fatalf("history=%+v err=%v", history, err) + } + claim, err := st.GetPluginKanbanClaimByPath( + ctx, automation.ID, "workspace", "cards/payment.md", + ) + if err != nil || claim.LatestOccurrenceID != first.Occurrence.ID { + t.Fatalf("claim=%+v err=%v", claim, err) + } +} + +func TestPluginKanbanBlockedOccurrenceResumesAndTracksReceiptPhase(t *testing.T) { + st, automation, trigger := seedPluginKanbanOccurrenceStore(t) + first := observePluginCard(t, st, automation, trigger, "event:1", "agent", int64PtrStore(1)) + + blocked, err := st.SetPluginKanbanOccurrenceBlocked( + context.Background(), first.Occurrence.ID, + "model_not_configured", "Choose a model for this Service.", "project_owner", + ) + if err != nil || blocked.State != domain.KanbanOccurrenceBlocked || + blocked.ReceiptPhase != "blocked" || blocked.ReasonCode != "model_not_configured" || + blocked.WritebackState != "not_required" { + t.Fatalf("blocked occurrence = %+v, %v", blocked, err) + } + pending, err := st.ListPluginKanbanDispatchableOccurrences(context.Background(), automation.ID, 10) + if err != nil || len(pending) != 1 || pending[0].ID != first.Occurrence.ID { + t.Fatalf("dispatchable = %+v, %v", pending, err) + } + receipts, err := st.ListPluginKanbanReceiptPending(context.Background(), automation.ID, 10) + if err != nil || len(receipts) != 1 || receipts[0].ReceiptPhase != "blocked" { + t.Fatalf("pending receipts = %+v, %v", receipts, err) + } + if err := st.MarkPluginKanbanOccurrenceReceipt( + context.Background(), first.Occurrence.ID, "blocked", nil, "temporary JType failure", + ); err != nil { + t.Fatal(err) + } + receipts, _ = st.ListPluginKanbanReceiptPending(context.Background(), automation.ID, 10) + if len(receipts) != 1 || receipts[0].WritebackError == "" { + t.Fatalf("failed receipt was hidden: %+v", receipts) + } + writtenAt := time.Now().UTC() + if err := st.MarkPluginKanbanOccurrenceReceipt( + context.Background(), first.Occurrence.ID, "blocked", &writtenAt, "", + ); err != nil { + t.Fatal(err) + } + receipts, _ = st.ListPluginKanbanReceiptPending(context.Background(), automation.ID, 10) + if len(receipts) != 0 { + t.Fatalf("written blocked receipt remained pending: %+v", receipts) + } + changed, err := st.SetPluginKanbanOccurrenceBlocked( + context.Background(), first.Occurrence.ID, + "repository_not_configured", "Configure a repository.", "project_owner", + ) + if err != nil || changed.ReceiptWrittenAt != nil { + t.Fatalf("changed blocker did not reopen external receipt: %+v, %v", changed, err) + } + + run := &domain.Run{ + ID: "blocked-run", ProjectID: "project", ServiceID: "service", Status: domain.StatusQueued, + Origin: domain.RunOriginKanban, OriginAutomationID: automation.ID, + OriginEventKey: first.Occurrence.ID, CreatedAt: time.Now().UTC(), + } + attached, err := st.CreatePluginKanbanOccurrenceRun(context.Background(), first.Occurrence.ID, run) + if err != nil || !attached { + t.Fatalf("resume = %v, %v", attached, err) + } + active, err := st.SetPluginKanbanOccurrenceReceiptPhase( + context.Background(), first.Occurrence.ID, PluginKanbanAlreadyRunning, + ) + if err != nil || active.ReceiptPhase != PluginKanbanAlreadyRunning || + active.ReceiptWrittenAt != nil { + t.Fatalf("active receipt phase=%+v err=%v", active, err) + } + activeWrittenAt := time.Now().UTC() + if err := st.MarkPluginKanbanOccurrenceReceipt( + context.Background(), first.Occurrence.ID, + PluginKanbanAlreadyRunning, &activeWrittenAt, "", + ); err != nil { + t.Fatal(err) + } + active, err = st.SetPluginKanbanOccurrenceReceiptPhase( + context.Background(), first.Occurrence.ID, PluginKanbanAlreadyRunning, + ) + if err != nil || active.ReceiptWrittenAt == nil { + t.Fatalf("same receipt phase lost idempotency marker: %+v err=%v", active, err) + } + writebackPending, err := st.SetPluginKanbanOccurrenceReceiptPhase( + context.Background(), first.Occurrence.ID, PluginKanbanWritebackPending, + ) + if err != nil || writebackPending.ReceiptWrittenAt != nil { + t.Fatalf("new receipt phase did not reopen projection: %+v err=%v", writebackPending, err) + } + history, err := st.ListPluginKanbanOccurrences(context.Background(), automation.ID, "card", 10) + if err != nil || len(history) != 1 || history[0].State != domain.KanbanOccurrenceQueued || + history[0].ReceiptPhase != PluginKanbanWritebackPending || + history[0].ReceiptWrittenAt != nil { + t.Fatalf("resumed history = %+v, %v", history, err) + } +} diff --git a/orchestrator/internal/store/plugins.go b/orchestrator/internal/store/plugins.go index 830e85c3..aef47239 100644 --- a/orchestrator/internal/store/plugins.go +++ b/orchestrator/internal/store/plugins.go @@ -521,7 +521,7 @@ func (s *PGStore) CreatePluginAutomation(ctx context.Context, a *domain.PluginAu } } if kanban != nil { - if _, err = tx.Exec(ctx, `INSERT INTO automation_kanban_triggers(automation_id,installation_id,board_ref,trigger_column,done_column)VALUES($1,$2,$3,$4,$5)`, a.ID, kanban.InstallationID, kanban.BoardRef, kanban.TriggerColumn, kanban.DoneColumn); err != nil { + if _, err = tx.Exec(ctx, `INSERT INTO automation_kanban_triggers(automation_id,installation_id,board_ref,trigger_column,done_column,trigger_label,done_label)VALUES($1,$2,$3,$4,$5,$6,$7)`, a.ID, kanban.InstallationID, kanban.BoardRef, kanban.TriggerColumn, kanban.DoneColumn, kanban.TriggerLabel, kanban.DoneLabel); err != nil { if isUniqueViolation(err) { return ErrAlreadyExists } @@ -568,7 +568,7 @@ func (s *PGStore) GetPluginAutomationSpec(ctx context.Context, id string) (*doma } case "kanban": var v domain.KanbanTrigger - if err := s.pool.QueryRow(ctx, `SELECT automation_id,installation_id,board_ref,trigger_column,done_column FROM automation_kanban_triggers WHERE automation_id=$1`, id).Scan(&v.AutomationID, &v.InstallationID, &v.BoardRef, &v.TriggerColumn, &v.DoneColumn); err != nil { + if err := s.pool.QueryRow(ctx, `SELECT automation_id,installation_id,board_ref,trigger_column,done_column,trigger_label,done_label,event_cursor,bootstrapped_at FROM automation_kanban_triggers WHERE automation_id=$1`, id).Scan(&v.AutomationID, &v.InstallationID, &v.BoardRef, &v.TriggerColumn, &v.DoneColumn, &v.TriggerLabel, &v.DoneLabel, &v.EventCursor, &v.BootstrappedAt); err != nil { return nil, fmt.Errorf("get kanban trigger: %w", err) } spec.Kanban = &v @@ -659,71 +659,228 @@ func (s *PGStore) EnsurePluginKanbanClaim(ctx context.Context, automationID, doc if err != nil { return nil, fmt.Errorf("ensure Kanban Automation claim: %w", err) } - var claim domain.PluginKanbanClaim - err = s.pool.QueryRow(ctx, `SELECT automation_id,installation_id,document_id,document_path,workspace_id,done_column,COALESCE(run_id,''),writeback_at,created_at FROM automation_kanban_claims WHERE automation_id=$1 AND document_id=$2`, automationID, documentID). - Scan(&claim.AutomationID, &claim.InstallationID, &claim.DocumentID, &claim.DocumentPath, &claim.WorkspaceID, &claim.DoneColumn, &claim.RunID, &claim.WritebackAt, &claim.CreatedAt) + claim, err := scanPluginKanbanClaim(s.pool.QueryRow(ctx, + `SELECT `+pluginKanbanClaimCols+` FROM automation_kanban_claims WHERE automation_id=$1 AND document_id=$2`, + automationID, documentID)) if err != nil { return nil, fmt.Errorf("load Kanban Automation claim: %w", err) } - return &claim, nil + return claim, nil } func (s *PGStore) SetPluginKanbanClaimRun(ctx context.Context, automationID, documentID, runID string) error { - tag, err := s.pool.Exec(ctx, `UPDATE automation_kanban_claims SET run_id=$3 WHERE automation_id=$1 AND document_id=$2 AND run_id IS NULL`, automationID, documentID, runID) + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("set Kanban Automation claim run: begin: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + claim, err := scanPluginKanbanClaim(tx.QueryRow(ctx, + `SELECT `+pluginKanbanClaimCols+` FROM automation_kanban_claims WHERE automation_id=$1 AND document_id=$2 FOR UPDATE`, + automationID, documentID)) + if err != nil { + return err + } + if claim.RunID != "" { + return ErrAlreadyExists + } + occurrenceID := claim.LatestOccurrenceID + if occurrenceID == "" { + var serviceID, triggerColumn string + if err = tx.QueryRow(ctx, `SELECT a.service_id,t.trigger_column + FROM automations_v2 a JOIN automation_kanban_triggers t ON t.automation_id=a.id + WHERE a.id=$1`, automationID).Scan(&serviceID, &triggerColumn); err != nil { + return fmt.Errorf("set Kanban Automation claim run: load trigger: %w", err) + } + occurrenceID = domain.NewID() + now := time.Now().UTC() + if _, err = tx.Exec(ctx, `INSERT INTO automation_kanban_occurrences( + id,automation_id,service_id,installation_id,workspace_id,document_id,document_path, + done_column,event_key,entry_column,state,run_id,writeback_state,created_at,updated_at + ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'queued',$11,'pending',$12,$12)`, + occurrenceID, automationID, serviceID, claim.InstallationID, claim.WorkspaceID, + documentID, claim.DocumentPath, claim.DoneColumn, "legacy:"+documentID+":"+runID, + triggerColumn, runID, now); err != nil { + return fmt.Errorf("set Kanban Automation claim run: create occurrence: %w", err) + } + } else { + tag, updateErr := tx.Exec(ctx, `UPDATE automation_kanban_occurrences + SET run_id=$2,state='queued',updated_at=now() WHERE id=$1 AND run_id IS NULL`, + occurrenceID, runID) + if updateErr != nil { + return fmt.Errorf("set Kanban Automation claim run: update occurrence: %w", updateErr) + } + if tag.RowsAffected() == 0 { + return ErrAlreadyExists + } + } + tag, err := tx.Exec(ctx, `UPDATE automation_kanban_claims + SET run_id=$3,latest_occurrence_id=$4,updated_at=now() + WHERE automation_id=$1 AND document_id=$2 AND run_id IS NULL`, + automationID, documentID, runID, occurrenceID) if err != nil { return fmt.Errorf("set Kanban Automation claim run: %w", err) } if tag.RowsAffected() == 0 { return ErrAlreadyExists } + if err = tx.Commit(ctx); err != nil { + return fmt.Errorf("set Kanban Automation claim run: commit: %w", err) + } return nil } type PluginKanbanWriteback struct { - Claim domain.PluginKanbanClaim - Run domain.Run + Claim domain.PluginKanbanClaim + Occurrence *domain.PluginKanbanOccurrence + Run domain.Run } func (s *PGStore) ListPluginKanbanRunsAwaitingWriteback(ctx context.Context) ([]PluginKanbanWriteback, error) { - rows, err := s.pool.Query(ctx, `SELECT - c.automation_id,c.installation_id,c.document_id,c.document_path,c.workspace_id,c.done_column,c.run_id,c.writeback_at,c.created_at - FROM automation_kanban_claims c - JOIN runs r ON r.id=c.run_id - WHERE c.writeback_at IS NULL AND r.status IN ('succeeded','failed','canceled') + rows, err := s.pool.Query(ctx, `SELECT `+qualifiedPluginKanbanOccurrenceCols+` + FROM automation_kanban_occurrences o + JOIN automation_kanban_claims c + ON c.automation_id=o.automation_id AND c.document_id=o.document_id + AND c.latest_occurrence_id=o.id AND c.writeback_at IS NULL + JOIN runs r ON r.id=o.run_id + WHERE o.writeback_state='pending' AND r.status IN ('succeeded','failed','canceled') ORDER BY r.finished_at NULLS LAST,r.created_at`) if err != nil { return nil, fmt.Errorf("list Kanban Automation writebacks: %w", err) } defer rows.Close() - var claims []domain.PluginKanbanClaim + var occurrences []domain.PluginKanbanOccurrence for rows.Next() { - var claim domain.PluginKanbanClaim - if err := rows.Scan(&claim.AutomationID, &claim.InstallationID, &claim.DocumentID, &claim.DocumentPath, &claim.WorkspaceID, &claim.DoneColumn, &claim.RunID, &claim.WritebackAt, &claim.CreatedAt); err != nil { + occurrence, err := scanPluginKanbanOccurrence(rows) + if err != nil { return nil, err } - claims = append(claims, claim) + occurrences = append(occurrences, *occurrence) } if err := rows.Err(); err != nil { return nil, err } rows.Close() - out := make([]PluginKanbanWriteback, 0, len(claims)) - for _, claim := range claims { - run, err := s.GetRun(ctx, claim.RunID) + out := make([]PluginKanbanWriteback, 0, len(occurrences)) + for i := range occurrences { + occurrence := occurrences[i] + run, err := s.GetRun(ctx, occurrence.RunID) if err != nil { return nil, fmt.Errorf("load Kanban Automation writeback run: %w", err) } - out = append(out, PluginKanbanWriteback{Claim: claim, Run: *run}) + claim, err := scanPluginKanbanClaim(s.pool.QueryRow(ctx, + `SELECT `+pluginKanbanClaimCols+` FROM automation_kanban_claims + WHERE automation_id=$1 AND document_id=$2 AND latest_occurrence_id=$3`, + occurrence.AutomationID, occurrence.DocumentID, occurrence.ID)) + if err != nil { + return nil, fmt.Errorf("load Kanban Automation writeback claim: %w", err) + } + out = append(out, PluginKanbanWriteback{Claim: *claim, Occurrence: &occurrence, Run: *run}) } return out, nil } -func (s *PGStore) MarkPluginKanbanWriteback(ctx context.Context, automationID, documentID string, at time.Time) (bool, error) { - tag, err := s.pool.Exec(ctx, `UPDATE automation_kanban_claims SET writeback_at=$3 WHERE automation_id=$1 AND document_id=$2 AND writeback_at IS NULL`, automationID, documentID, at) +func (s *PGStore) MarkPluginKanbanWriteback( + ctx context.Context, + automationID, documentID, occurrenceID string, + outcome domain.RunStatus, + terminalAt *time.Time, + at time.Time, +) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("mark Kanban Automation writeback: begin: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + if occurrenceID != "" { + finishedAt := at + if terminalAt != nil { + finishedAt = *terminalAt + } + tag, updateErr := tx.Exec(ctx, `UPDATE automation_kanban_occurrences o SET + state='terminal',outcome=$4,receipt_phase='terminal', + receipt_written_at=$6,writeback_state='complete',writeback_error='', + terminal_at=$5,updated_at=$6 + FROM automation_kanban_claims c + WHERE c.automation_id=$1 AND c.document_id=$2 + AND c.latest_occurrence_id=$3 AND c.writeback_at IS NULL + AND o.id=$3 AND o.writeback_state='pending'`, + automationID, documentID, occurrenceID, outcome, finishedAt, at) + if updateErr != nil { + return false, fmt.Errorf("mark Kanban Automation occurrence writeback: %w", updateErr) + } + if tag.RowsAffected() == 0 { + return false, nil + } + } + tag, err := tx.Exec(ctx, `UPDATE automation_kanban_claims + SET writeback_at=$4,updated_at=$4 + WHERE automation_id=$1 AND document_id=$2 + AND COALESCE(latest_occurrence_id,'')=$3 + AND writeback_at IS NULL`, + automationID, documentID, occurrenceID, at) if err != nil { return false, fmt.Errorf("mark Kanban Automation writeback: %w", err) } - return tag.RowsAffected() == 1, nil + if tag.RowsAffected() == 0 { + return false, fmt.Errorf("mark Kanban Automation writeback: claim changed during occurrence update") + } + if err = tx.Commit(ctx); err != nil { + return false, fmt.Errorf("mark Kanban Automation writeback: commit: %w", err) + } + return true, nil +} + +func (s *PGStore) MarkPluginKanbanWritebackUnavailable( + ctx context.Context, + automationID, documentID, occurrenceID string, + outcome domain.RunStatus, + terminalAt *time.Time, + message string, + at time.Time, +) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("mark Kanban Automation writeback unavailable: begin: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + if occurrenceID != "" { + finishedAt := at + if terminalAt != nil { + finishedAt = *terminalAt + } + tag, updateErr := tx.Exec(ctx, `UPDATE automation_kanban_occurrences o SET + state='terminal',outcome=$4,receipt_phase='terminal', + writeback_state='unavailable',writeback_error=$6, + terminal_at=$5,updated_at=$7 + FROM automation_kanban_claims c + WHERE c.automation_id=$1 AND c.document_id=$2 + AND c.latest_occurrence_id=$3 AND c.writeback_at IS NULL + AND o.id=$3 AND o.writeback_state='pending'`, + automationID, documentID, occurrenceID, outcome, finishedAt, message, at) + if updateErr != nil { + return false, fmt.Errorf("mark Kanban Automation occurrence writeback unavailable: %w", updateErr) + } + if tag.RowsAffected() == 0 { + return false, nil + } + } + tag, err := tx.Exec(ctx, `UPDATE automation_kanban_claims + SET writeback_at=$4,external_ref_available=FALSE,last_observed_column='', + outside_trigger_at=$4,updated_at=$4 + WHERE automation_id=$1 AND document_id=$2 + AND COALESCE(latest_occurrence_id,'')=$3 + AND writeback_at IS NULL`, + automationID, documentID, occurrenceID, at) + if err != nil { + return false, fmt.Errorf("mark Kanban Automation writeback unavailable: %w", err) + } + if tag.RowsAffected() == 0 { + return false, fmt.Errorf("mark Kanban Automation writeback unavailable: claim changed during occurrence update") + } + if err = tx.Commit(ctx); err != nil { + return false, fmt.Errorf("mark Kanban Automation writeback unavailable: commit: %w", err) + } + return true, nil } func (s *PGStore) ListPluginAutomationsByProject(ctx context.Context, projectID string) ([]domain.PluginAutomation, error) { rows, err := s.pool.Query(ctx, `SELECT `+qualifiedPluginAutomationCols("a")+` FROM automations_v2 a JOIN services s ON s.id=a.service_id WHERE s.project_id=$1 ORDER BY a.created_at DESC`, projectID) @@ -831,7 +988,7 @@ func (s *PGStore) ReplacePluginAutomationSpec(ctx context.Context, a *domain.Plu } } if kanban != nil { - if _, err = tx.Exec(ctx, `INSERT INTO automation_kanban_triggers(automation_id,installation_id,board_ref,trigger_column,done_column)VALUES($1,$2,$3,$4,$5)`, a.ID, kanban.InstallationID, kanban.BoardRef, kanban.TriggerColumn, kanban.DoneColumn); err != nil { + if _, err = tx.Exec(ctx, `INSERT INTO automation_kanban_triggers(automation_id,installation_id,board_ref,trigger_column,done_column,trigger_label,done_label)VALUES($1,$2,$3,$4,$5,$6,$7)`, a.ID, kanban.InstallationID, kanban.BoardRef, kanban.TriggerColumn, kanban.DoneColumn, kanban.TriggerLabel, kanban.DoneLabel); err != nil { if isUniqueViolation(err) { return ErrAlreadyExists } @@ -851,7 +1008,12 @@ func (s *PGStore) DeletePluginAutomation(ctx context.Context, id string) error { return fmt.Errorf("delete plugin automation: %w", err) } defer tx.Rollback(ctx) - if _, err := tx.Exec(ctx, `DELETE FROM automation_kanban_claims WHERE automation_id=$1 AND run_id IS NULL`, id); err != nil { + if _, err := tx.Exec(ctx, `DELETE FROM automation_kanban_claims c + WHERE c.automation_id=$1 AND c.run_id IS NULL + AND NOT EXISTS ( + SELECT 1 FROM automation_kanban_occurrences o + WHERE o.automation_id=c.automation_id AND o.document_id=c.document_id + )`, id); err != nil { return fmt.Errorf("delete plugin automation observations: %w", err) } tag, err := tx.Exec(ctx, `DELETE FROM automations_v2 WHERE id=$1`, id) diff --git a/orchestrator/internal/store/plugins_test.go b/orchestrator/internal/store/plugins_test.go index e70f6beb..fabc025d 100644 --- a/orchestrator/internal/store/plugins_test.go +++ b/orchestrator/internal/store/plugins_test.go @@ -167,7 +167,10 @@ func TestPluginSecretVersionGCRetainsActiveRunAndTerminalSnapshotAudit(t *testin if _, ok := st.providerConfigVersions[originalProviderKey]; !ok { t.Fatal("pending Kanban writeback lost its provider configuration version") } - if wrote, err := st.MarkPluginKanbanWriteback(ctx, pendingClaim.AutomationID, pendingClaim.DocumentID, time.Now().UTC()); err != nil || !wrote { + if wrote, err := st.MarkPluginKanbanWriteback( + ctx, pendingClaim.AutomationID, pendingClaim.DocumentID, "", + domain.StatusSucceeded, nil, time.Now().UTC(), + ); err != nil || !wrote { t.Fatalf("mark writeback wrote=%v err=%v", wrote, err) } if credentials, providers, err := st.DeleteUnreferencedPluginSecretVersions(ctx, 100); err != nil || credentials != 1 || providers != 1 { @@ -285,7 +288,9 @@ func TestPGPluginSecretVersionGCAndSnapshotFKMigration(t *testing.T) { if err := st.Pool().QueryRow(ctx, `SELECT count(*) FROM provider_config_versions WHERE provider=$1 AND config_revision=$2`, domain.PluginGitLab, snapshotProviderRevision).Scan(&providerStillPinnedByWriteback); err != nil || providerStillPinnedByWriteback != 1 { t.Fatalf("pending writeback provider version retained=%d err=%v, want 1,nil", providerStillPinnedByWriteback, err) } - if wrote, err := st.MarkPluginKanbanWriteback(ctx, automationID, documentID, time.Now().UTC()); err != nil || !wrote { + if wrote, err := st.MarkPluginKanbanWriteback( + ctx, automationID, documentID, "", domain.StatusSucceeded, nil, time.Now().UTC(), + ); err != nil || !wrote { t.Fatalf("mark PG writeback wrote=%v err=%v", wrote, err) } if _, _, err := st.DeleteUnreferencedPluginSecretVersions(ctx, 100); err != nil { diff --git a/orchestrator/internal/store/store.go b/orchestrator/internal/store/store.go index f264ce67..90780f7b 100644 --- a/orchestrator/internal/store/store.go +++ b/orchestrator/internal/store/store.go @@ -546,10 +546,23 @@ type Store interface { ListEnabledCronAutomations(ctx context.Context) ([]domain.PluginAutomationSpec, error) AdvancePluginCronAutomation(ctx context.Context, id string, previous, firedAt *time.Time, lastError string) (bool, error) ListEnabledKanbanAutomations(ctx context.Context) ([]domain.PluginAutomationSpec, error) + ObservePluginKanbanCard(ctx context.Context, observation PluginKanbanObservation) (*PluginKanbanObservationResult, error) + CreatePluginKanbanOccurrenceRun(ctx context.Context, occurrenceID string, run *domain.Run) (bool, error) + SetPluginKanbanOccurrenceBlocked(ctx context.Context, occurrenceID, reasonCode, reasonMessage, repairRole string) (*domain.PluginKanbanOccurrence, error) + ListPluginKanbanDispatchableOccurrences(ctx context.Context, automationID string, limit int) ([]domain.PluginKanbanOccurrence, error) + ListPluginKanbanReceiptPending(ctx context.Context, automationID string, limit int) ([]domain.PluginKanbanOccurrence, error) + SetPluginKanbanOccurrenceReceiptPhase(ctx context.Context, occurrenceID, phase string) (*domain.PluginKanbanOccurrence, error) + MarkPluginKanbanOccurrenceReceipt(ctx context.Context, occurrenceID, phase string, writtenAt *time.Time, writebackError string) error + ListPluginKanbanOccurrences(ctx context.Context, automationID, documentID string, limit int) ([]domain.PluginKanbanOccurrence, error) + GetPluginKanbanClaimByPath(ctx context.Context, automationID, workspaceID, documentPath string) (*domain.PluginKanbanClaim, error) + MarkPluginKanbanCardUnavailable(ctx context.Context, automationID, workspaceID, documentPath string, at time.Time) (bool, error) + ListPluginKanbanCardExecutions(ctx context.Context, automationID, serviceID, workspaceID, documentPath string, before *PluginKanbanOccurrenceCursor, limit int) ([]domain.PluginKanbanOccurrence, error) + AdvancePluginKanbanTrigger(ctx context.Context, automationID string, previousCursor, nextCursor int64, bootstrappedAt *time.Time) (bool, error) EnsurePluginKanbanClaim(ctx context.Context, automationID, documentID, documentPath, workspaceID, doneColumn string) (*domain.PluginKanbanClaim, error) SetPluginKanbanClaimRun(ctx context.Context, automationID, documentID, runID string) error ListPluginKanbanRunsAwaitingWriteback(ctx context.Context) ([]PluginKanbanWriteback, error) - MarkPluginKanbanWriteback(ctx context.Context, automationID, documentID string, at time.Time) (bool, error) + MarkPluginKanbanWriteback(ctx context.Context, automationID, documentID, occurrenceID string, outcome domain.RunStatus, terminalAt *time.Time, at time.Time) (bool, error) + MarkPluginKanbanWritebackUnavailable(ctx context.Context, automationID, documentID, occurrenceID string, outcome domain.RunStatus, terminalAt *time.Time, message string, at time.Time) (bool, error) ClaimWebhookReceipt(ctx context.Context, receipt *domain.WebhookReceipt) (claimed bool, err error) CompleteWebhookReceipt(ctx context.Context, receipt *domain.WebhookReceipt) error