diff --git a/console/package.json b/console/package.json index 6c3f2eda..413d30c5 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": "github:cnjack/jtype#939d7177676376942a8059b096d2c26205472239&path:/packages/board-react", + "jtype-board-react": "github:cnjack/jtype#a83f55b8112cc534f42c66e9f8f1b6274ce3706d&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 84fc38f4..7fa1594f 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: 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) + specifier: github:cnjack/jtype#a83f55b8112cc534f42c66e9f8f1b6274ce3706d&path:/packages/board-react + version: https://codeload.github.com/cnjack/jtype/tar.gz/a83f55b8112cc534f42c66e9f8f1b6274ce3706d#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 @@ -1194,8 +1194,8 @@ packages: engines: {node: '>=6'} hasBin: true - 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} + jtype-board-react@https://codeload.github.com/cnjack/jtype/tar.gz/a83f55b8112cc534f42c66e9f8f1b6274ce3706d#path:/packages/board-react: + resolution: {gitHosted: true, integrity: sha512-1aLCgPjtU2y/oahY0Pt+JZoRfcnBR5ljwEee31RIBh8UuAwj2Z0Arclsr0kce0l+5EUpEiC1vdwlizDYZKQUQA==, path: /packages/board-react, tarball: https://codeload.github.com/cnjack/jtype/tar.gz/a83f55b8112cc534f42c66e9f8f1b6274ce3706d} version: 0.1.2 peerDependencies: react: ^18.2.0 || ^19.0.0 @@ -2532,7 +2532,7 @@ snapshots: json5@2.2.3: {} - 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): + jtype-board-react@https://codeload.github.com/cnjack/jtype/tar.gz/a83f55b8112cc534f42c66e9f8f1b6274ce3706d#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/App.tsx b/console/src/App.tsx index e9a1557b..ffdf8ae6 100644 --- a/console/src/App.tsx +++ b/console/src/App.tsx @@ -9,6 +9,7 @@ import { NewProjectPage } from './pages/NewProjectPage'; import { ProjectDetailPage } from './pages/ProjectDetailPage'; import { ProjectPluginDetailPage } from './pages/ProjectPluginDetailPage'; import { AutomationEditorPage } from './pages/AutomationEditorPage'; +import { AutomationDetailPage } from './pages/AutomationDetailPage'; import { RunDetailPage } from './pages/RunDetailPage'; import { DevicesPage } from './pages/DevicesPage'; import { DeviceWelcomePage } from './pages/DeviceWelcomePage'; @@ -70,6 +71,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/console/src/api/client.ts b/console/src/api/client.ts index 7b94ff01..acfc542e 100644 --- a/console/src/api/client.ts +++ b/console/src/api/client.ts @@ -41,6 +41,8 @@ import type { PrInfo, Project, ProjectAutomationSpec, + AutomationExecution, + AutomationExecutionsPage, ProjectPlugin, PluginAuditEvent, PluginConsentInput, @@ -337,6 +339,9 @@ export interface ApiClient { createProjectAutomation(projectId: string, input: CreateProjectAutomationInput): Promise; updateProjectAutomation(projectId: string, automationId: string, input: UpdateProjectAutomationInput): Promise; deleteProjectAutomation(projectId: string, automationId: string): Promise; + listAutomationExecutions(automationId: string, before?: string, state?: string, limit?: number): Promise; + getAutomationExecution(automationId: string, executionId: string): Promise; + runAutomationNow(automationId: string, idempotencyKey: string): Promise; getServiceKanban(serviceId: string): Promise; getServiceKanbanPolicy(serviceId: string): Promise; listServiceKanbanCardExecutions(serviceId: string, workspaceId: string, documentPath: string, before?: string, limit?: number): Promise; @@ -827,6 +832,18 @@ export function createHttpClient( }), deleteProjectAutomation: (_projectId, automationId) => req(`/automations/${encodeURIComponent(automationId)}`, { method: 'DELETE' }), + listAutomationExecutions: (automationId, before, state, limit = 20) => { + const params = new URLSearchParams({ limit: String(limit) }); + if (before) params.set('before', before); + if (state) params.set('state', state); + return req(`/automations/${encodeURIComponent(automationId)}/executions?${params.toString()}`); + }, + getAutomationExecution: (automationId, executionId) => + req(`/automations/${encodeURIComponent(automationId)}/executions/${encodeURIComponent(executionId)}`), + runAutomationNow: (automationId, idempotencyKey) => + req(`/automations/${encodeURIComponent(automationId)}/executions`, { + method: 'POST', body: JSON.stringify({ idempotency_key: idempotencyKey }), + }), getServiceKanban: (serviceId) => req(`/services/${encodeURIComponent(serviceId)}/kanban`), getServiceKanbanPolicy: (serviceId) => req(`/services/${encodeURIComponent(serviceId)}/kanban/policy`), listServiceKanbanCardExecutions: (serviceId, workspaceId, documentPath, before, limit = 20) => { diff --git a/console/src/api/mockClient.ts b/console/src/api/mockClient.ts index 235a5091..82c17e12 100644 --- a/console/src/api/mockClient.ts +++ b/console/src/api/mockClient.ts @@ -22,6 +22,8 @@ import type { import type { AddMemberInput, ApiKey, + AutomationExecution, + AutomationExecutionsPage, BoardEmbedLink, CatalogModel, ClusterProviderConfig, @@ -262,6 +264,8 @@ export function createMockClient(): ApiClient { ], }; const projectAutomations = new Map(); + const automationExecutions = new Map(); + const automationIdempotency = new Map(); const projectPlugins = new Map>(); const clusterProviders = new Map((['github', 'gitlab', 'gitea', 'jtype'] as const).map((provider): [ProviderKind, ClusterProviderConfig] => [provider, { provider, base_url: provider === 'github' ? 'https://github.com' : '', login_enabled: provider !== 'jtype', plugin_enabled: true, configured: false, health: 'unknown' }])); @@ -2171,6 +2175,64 @@ export function createMockClient(): ApiClient { projectAutomations.delete(automationId); return delay(undefined); }, + async listAutomationExecutions(automationId: string, _before?: string, state?: string, limit = 20): Promise { + const items = (automationExecutions.get(automationId) ?? []) + .filter((item) => !state || item.state === state) + .slice(0, limit); + return delay({ items: structuredClone(items), next_cursor: null }); + }, + async getAutomationExecution(automationId: string, executionId: string): Promise { + const item = (automationExecutions.get(automationId) ?? []).find((value) => value.id === executionId); + if (!item) throw new ApiError(404, 'automation execution not found'); + return delay(structuredClone(item)); + }, + async runAutomationNow(automationId: string, idempotencyKey: string): Promise { + const spec = projectAutomations.get(automationId); + if (!spec) throw new ApiError(404, 'automation not found'); + const replayId = automationIdempotency.get(`${automationId}|${idempotencyKey}`); + const existing = (automationExecutions.get(automationId) ?? []).find((item) => item.id === replayId); + if (existing) return delay(structuredClone(existing)); + const service = [...services.values()].flat().find((item) => item.id === spec.automation.service_id); + if (!service) throw new ApiError(404, 'service not found'); + const now = nowISO(); + const outputMode = spec.cron?.output_mode ?? 'run_only'; + const id = genId('execution'); + let run: StoredRun | undefined; + if (outputMode === 'run_only' && spec.automation.run_kind !== 'review') { + run = makeRun(service.project_id, service.id, spec.automation.prompt_template); + } + const item: AutomationExecution = { + id, + automation_id: automationId, + automation_name: spec.automation.name, + trigger_kind: 'manual', + state: run ? 'queued' : spec.automation.run_kind === 'review' ? 'blocked' : 'accepted', + output_mode: outputMode, + reason_code: spec.automation.run_kind === 'review' ? 'manual_review_requires_pull_request' : undefined, + reason: spec.automation.run_kind === 'review' ? 'Run now needs a pull request for native review output.' : undefined, + repair_role: spec.automation.run_kind === 'review' ? 'project_owner' : undefined, + requested_actor: { kind: 'cloud_user', id: 'u_ada', label: 'Ada Lovelace' }, + accountable_actor: { kind: 'cloud_user', id: 'u_ada', label: 'Ada Lovelace' }, + output: run + ? { kind: 'run', label: `Run ${run.id}`, href: `/runs/${run.id}`, available: true } + : outputMode === 'create_card' + ? { kind: 'card', label: `jcode-automation/${automationId}/${id}.md`, available: false } + : { kind: 'none', label: 'No output', available: false }, + run: run ? { id: run.id, status: run.status, href: `/runs/${run.id}` } : null, + card: outputMode === 'create_card' ? { + workspace_id: '', + document_path: `jcode-automation/${automationId}/${id}.md`, + available: false, + } : null, + writeback_state: outputMode === 'create_card' ? 'pending' : '', + usage: { state: 'unavailable' }, + created_at: now, + updated_at: now, + }; + automationExecutions.set(automationId, [item, ...(automationExecutions.get(automationId) ?? [])]); + automationIdempotency.set(`${automationId}|${idempotencyKey}`, id); + return delay(structuredClone(item)); + }, async getServiceKanban(serviceId: string): Promise { const spec = [...projectAutomations.values()].find((item) => item.automation.service_id === serviceId && item.automation.trigger_kind === 'kanban'); diff --git a/console/src/api/queries.test.ts b/console/src/api/queries.test.ts new file mode 100644 index 00000000..9276d930 --- /dev/null +++ b/console/src/api/queries.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import type { AutomationExecution, KanbanCardExecution } from './types'; +import { + automationExecutionPollInterval, + kanbanCardExecutionPollInterval, +} from './queries'; + +function execution(status: KanbanCardExecution['status']): KanbanCardExecution { + return { + id: `occ-${status}`, + status, + summary: status, + reason: null, + repair_role: null, + requested_actor: null, + run: null, + receipt: { external: 'not_required', writeback: 'not_required' }, + created_at: '', + updated_at: '', + }; +} + +describe('kanbanCardExecutionPollInterval', () => { + it('keeps polling an empty Card until its first receipt appears', () => { + expect(kanbanCardExecutionPollInterval(undefined)).toBe(5_000); + expect(kanbanCardExecutionPollInterval([])).toBe(5_000); + }); + + it('polls active or repairable receipts and stops for terminal-only history', () => { + for (const status of ['received', 'blocked', 'queued', 'running'] as const) { + expect(kanbanCardExecutionPollInterval([execution(status)])).toBe(5_000); + } + expect(kanbanCardExecutionPollInterval([execution('terminal')])).toBe(false); + }); +}); + +function automationExecution( + state: AutomationExecution['state'], + outputMode: AutomationExecution['output_mode'] = 'run_only', +): AutomationExecution { + return { + id: `execution-${state}`, + automation_id: 'automation', + automation_name: 'Nightly', + trigger_kind: 'cron', + state, + output_mode: outputMode, + requested_actor: null, + accountable_actor: null, + output: { kind: 'none', label: 'No output', available: false }, + run: null, + card: null, + writeback_state: '', + usage: { state: 'unavailable' }, + created_at: '', + updated_at: '', + }; +} + +describe('automationExecutionPollInterval', () => { + it('polls active executions and blocked Card materialization that can recover', () => { + for (const state of ['accepted', 'queued', 'running'] as const) { + expect(automationExecutionPollInterval([automationExecution(state)])).toBe(5_000); + } + expect(automationExecutionPollInterval([ + automationExecution('blocked', 'create_card'), + ])).toBe(5_000); + }); + + it('stops for terminal decisions and blocked direct Runs', () => { + expect(automationExecutionPollInterval([ + automationExecution('terminal'), + automationExecution('ignored'), + automationExecution('blocked', 'run_only'), + ])).toBe(false); + }); +}); diff --git a/console/src/api/queries.ts b/console/src/api/queries.ts index 4a18d1d4..137ca5ca 100644 --- a/console/src/api/queries.ts +++ b/console/src/api/queries.ts @@ -11,6 +11,7 @@ import { import { useApi } from './ApiProvider'; import type { AddMemberInput, + AutomationExecution, CreateProjectAutomationInput, CreateApiKeyInput, CreateApiKeyResponse, @@ -21,6 +22,7 @@ import type { CreateRunInput, CreateServiceInput, ServiceBranch, + KanbanCardExecution, Member, Model, Project, @@ -41,6 +43,25 @@ import type { import { isTerminal } from './types'; import { reconcileRunSnapshot } from './runCache'; +export function kanbanCardExecutionPollInterval( + items: readonly KanbanCardExecution[] | undefined, +): number | false { + if (!items || items.length === 0) return 5_000; + return items.some((item) => + item.status === 'received' || item.status === 'blocked' || + item.status === 'queued' || item.status === 'running') + ? 5_000 : false; +} + +export function automationExecutionPollInterval( + items: readonly AutomationExecution[] | undefined, +): number | false { + return items?.some((item) => + item.state === 'accepted' || item.state === 'queued' || item.state === 'running' || + (item.state === 'blocked' && item.output_mode === 'create_card')) + ? 5_000 : false; +} + export const qk = { me: ['me'] as const, projects: ['projects'] as const, @@ -79,6 +100,8 @@ export const qk = { projectAutomations: (projectId: string) => ['project-automations', projectId] as const, projectAutomation: (projectId: string, automationId: string) => ['project-automation', projectId, automationId] as const, + automationExecutions: (automationId: string, state: string) => + ['automation-executions', automationId, state] as const, providerCapabilities: (provider: ProviderKind) => ['provider-capabilities', provider] as const, githubAppInstallations: (projectId: string) => ['github-app-installations', projectId] as const, jtypePluginConnect: (projectId: string, installationId: string, connectId: string) => @@ -791,11 +814,11 @@ export function useServiceKanbanCardExecutions( 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; + const items = query.state.data?.pages.flatMap((page) => page.items); + // Keep polling an empty Card so a receipt appears without closing and + // reopening the detail after the user moves it into the trigger column. + // Stop only once every known occurrence is terminal. + return kanbanCardExecutionPollInterval(items); }, }); } @@ -983,6 +1006,31 @@ export function useProjectAutomation(projectId: string, automationId: string, en }); } +export function useAutomationExecutions(automationId: string, state = '', enabled = true) { + const api = useApi(); + return useInfiniteQuery({ + queryKey: qk.automationExecutions(automationId, state), + queryFn: ({ pageParam }) => api.listAutomationExecutions( + automationId, pageParam ?? undefined, state || undefined, + ), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + enabled: enabled && !!automationId, + refetchInterval: (query) => automationExecutionPollInterval( + query.state.data?.pages.flatMap((page) => page.items), + ), + }); +} + +export function useRunAutomationNow(automationId: string) { + const api = useApi(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (idempotencyKey: string) => api.runAutomationNow(automationId, idempotencyKey), + onSuccess: () => qc.invalidateQueries({ queryKey: ['automation-executions', automationId] }), + }); +} + export function useProviderCapabilities(provider: ProviderKind, enabled = true) { const api = useApi(); return useQuery({ diff --git a/console/src/api/types.ts b/console/src/api/types.ts index 752f6cdc..c2d4d462 100644 --- a/console/src/api/types.ts +++ b/console/src/api/types.ts @@ -272,7 +272,43 @@ export interface ProjectAutomationSpec { done_column?: string; done_label?: string; }; - cron?: { automation_id?: string; cron_expr: string }; + cron?: { automation_id?: string; cron_expr: string; output_mode?: 'run_only' | 'create_card' }; +} +export type AutomationExecutionState = + | 'accepted' | 'ignored' | 'duplicate' | 'superseded' + | 'blocked' | 'queued' | 'running' | 'terminal'; +export interface AutomationExecution { + id: string; + automation_id: string; + automation_name: string; + trigger_kind: 'scm' | 'cron' | 'manual'; + state: AutomationExecutionState; + outcome?: 'succeeded' | 'failed' | 'canceled'; + output_mode: 'run_only' | 'create_card'; + reason_code?: string; + reason?: string; + repair_role?: 'project_owner' | 'cluster_admin'; + requested_actor: ProvenanceActorRef | null; + accountable_actor: ProvenanceActorRef | null; + output: { kind: 'run' | 'card' | 'none'; label: string; href?: string; available: boolean }; + run: { id: string; status: RunStatus; href: string } | null; + card: { + workspace_id: string; + document_id?: string; + document_path: string; + href?: string; + available: boolean; + } | null; + external_url?: string; + writeback_state: string; + usage: { state: 'unavailable' }; + created_at: string; + updated_at: string; + terminal_at?: string; +} +export interface AutomationExecutionsPage { + items: AutomationExecution[]; + next_cursor: string | null; } export type ServiceKanbanBinding = ProjectAutomationSpec; export interface ServiceKanbanPolicy { @@ -349,7 +385,7 @@ export interface CreateProjectAutomationInput { ignore_jcode?: boolean; scm?: { branch?: string; path_pattern?: string; conclusion?: string; include_drafts?: boolean; actions: ScmAutomationAction[] }; kanban?: { installation_id: string; board_ref: string; trigger_column: string; done_column?: string }; - cron?: { cron_expr: string }; + cron?: { cron_expr: string; output_mode?: 'run_only' | 'create_card' }; } export type UpdateProjectAutomationInput = Partial; diff --git a/console/src/i18n/locales/en.ts b/console/src/i18n/locales/en.ts index 714b9b71..0ab47b93 100644 --- a/console/src/i18n/locales/en.ts +++ b/console/src/i18n/locales/en.ts @@ -1446,6 +1446,11 @@ export default { eventUnavailable: 'Not supported by {provider}{version}.', cronExpression: 'Cron expression', cronHint: 'Five-field cron expression. The minimum interval is enforced by the server.', + outputMode: 'Output', + outputRun: 'Create a Run directly', + outputCard: 'Create a jtype Card, then use Service Kanban', + outputHint: 'Card output preserves the jtype Work Item and uses the normal Kanban claim and writeback loop.', + outputCardUnavailable: 'Enable a healthy Service Kanban policy before selecting Card output.', create: 'Create Automation', save: 'Save Automation', review: { @@ -1476,6 +1481,7 @@ export default { required: 'Name, Service, model, and task Prompt are required.', eventRequired: 'Select at least one event supported by this Service provider.', cronRequired: 'Cron expression is required.', + cardOutputUnavailable: 'Card output requires a healthy Service Kanban policy.', }, apiError: { overlap: 'Another Automation already uses one of the selected SCM events for this Service.', @@ -1637,6 +1643,56 @@ export default { send: 'Send', }, + automationExecutions: { + loading: 'Loading Automation…', + loadError: 'Could not load Automation.', + back: 'Back to Automations', + cardOutput: 'Card output', + runOutput: 'Direct Run output', + runNow: 'Run now', + runNowError: 'The execution could not be accepted. Retry uses the same idempotency key.', + history: 'Execution history', + loadingHistory: 'Loading execution history…', + historyError: 'Could not load execution history.', + empty: 'No executions yet.', + loadMore: 'Load older executions', + select: 'Select an execution to inspect it.', + selected: 'Selected execution', + execution: 'Execution', + trigger: 'Trigger', + kind: 'Kind', + requested: 'Requested by', + accountable: 'Accountable to', + notApplicable: 'Not applicable', + unattributed: 'Unattributed', + output: 'Output', + expected: 'Expected', + cardThenRun: 'jtype Card → Kanban Run', + directRun: 'Direct Run', + actual: 'Actual', + writeback: 'Writeback', + usage: 'Usage', + usageUnavailable: 'Unavailable', + filter: { all: 'All', blocked: 'Blocked', running: 'Running', terminal: 'Terminal' }, + state: { + accepted: 'Accepted', ignored: 'Ignored', duplicate: 'Duplicate', + superseded: 'Superseded', blocked: 'Blocked', queued: 'Queued', + running: 'Running', terminal: 'Terminal', + }, + outcome: { succeeded: 'Succeeded', failed: 'Failed', canceled: 'Canceled' }, + repair: { + project_owner: 'A Project owner can repair this dependency.', + cluster_admin: 'A Cluster administrator must repair this dependency.', + requester: 'The requester must repair this dependency.', + }, + title: { + blocked: 'Blocked before output', + ignored: 'Trigger intentionally ignored', + card: 'Card output', + run: 'Run output', + }, + }, + projectAutomations: { eyebrow: 'Project automation', title: 'Automations', diff --git a/console/src/i18n/locales/ja.ts b/console/src/i18n/locales/ja.ts index 4901e2f5..2ebd0801 100644 --- a/console/src/i18n/locales/ja.ts +++ b/console/src/i18n/locales/ja.ts @@ -1337,6 +1337,9 @@ export default { conclusion: 'CI 結果', conclusionHint: '選択したすべてのイベントが check.completed の場合のみ使用できます。', events: 'イベント', moreEvents: 'その他のイベント', thisProvider: 'このプロバイダー', eventUnavailable: '{provider}{version} ではサポートされていません。', cronExpression: 'Cron 式', cronHint: '5 フィールドの Cron 式を使用します。最小間隔はサーバーで強制されます。', + outputMode: '出力', outputRun: 'Run を直接作成', outputCard: 'jtype Card を作成して Service Kanban で実行', + outputHint: 'Card 出力は jtype の作業項目を残し、通常の Kanban 受付・書き戻しフローを使用します。', + outputCardUnavailable: '正常な Service Kanban を有効にしてから Card 出力を選択してください。', create: '自動化を作成', save: '自動化を保存', review: { eyebrow: 'GitHub コードレビュー', title: 'Pull Request をレビュー', subtitle: '新しい手順を覚えなくても、GitHub ネイティブのレビューを有効にできます。', @@ -1349,7 +1352,7 @@ export default { draftsTitle: 'Draft を含める', draftsBody: '通常はオフにして、作者の最初の確認を待ちます。', create: 'レビューを有効化', save: 'レビュー設定を保存', }, - validation: { noPermission: '自動化を編集する権限がありません。', required: '名前、サービス、モデル、タスクプロンプトは必須です。', eventRequired: 'このサービスのプロバイダーがサポートするイベントを 1 つ以上選択してください。', cronRequired: 'Cron 式は必須です。' }, + validation: { noPermission: '自動化を編集する権限がありません。', required: '名前、サービス、モデル、タスクプロンプトは必須です。', eventRequired: 'このサービスのプロバイダーがサポートするイベントを 1 つ以上選択してください。', cronRequired: 'Cron 式は必須です。', cardOutputUnavailable: 'Card 出力には正常な Service Kanban が必要です。' }, apiError: { overlap: '選択した SCM イベントのいずれかを、このサービスの別の自動化がすでに使用しています。', webhook: '自動化は保存されましたが、プロバイダー Webhook を同期できませんでした。プラグイン接続を確認して再試行してください。', @@ -1361,6 +1364,34 @@ export default { }, }, + automationExecutions: { + loading: '自動化を読み込み中…', loadError: '自動化を読み込めませんでした。', back: '自動化一覧に戻る', + cardOutput: 'Card 出力', runOutput: '直接 Run 出力', runNow: '今すぐ実行', + runNowError: '実行を受理できませんでした。再試行時も同じ冪等キーを使用します。', + history: '実行履歴', loadingHistory: '実行履歴を読み込み中…', historyError: '実行履歴を読み込めませんでした。', + empty: '実行履歴はまだありません。', loadMore: '以前の実行を読み込む', select: '実行を選択して詳細を確認します。', + selected: '選択した実行', execution: '実行', trigger: 'トリガー', kind: '種類', + requested: '依頼者', accountable: '責任者', notApplicable: '該当なし', unattributed: '帰属なし', + output: '出力', expected: '予定', cardThenRun: 'jtype Card → Kanban Run', directRun: '直接 Run', + actual: '実際', writeback: '書き戻し', usage: '使用量', usageUnavailable: '利用不可', + filter: { all: 'すべて', blocked: 'ブロック', running: '実行中', terminal: '完了済み' }, + state: { + accepted: '受付済み', ignored: '無視', duplicate: '重複', + superseded: '置換済み', blocked: 'ブロック', queued: '待機中', + running: '実行中', terminal: '完了済み', + }, + outcome: { succeeded: '成功', failed: '失敗', canceled: 'キャンセル' }, + repair: { + project_owner: 'プロジェクト所有者がこの依存関係を修復できます。', + cluster_admin: 'クラスター管理者がこの依存関係を修復する必要があります。', + requester: 'リクエストしたユーザーがこの依存関係を修復する必要があります。', + }, + title: { + blocked: '出力前にブロック', ignored: 'ルールにより無視', + card: 'Card 出力', run: 'Run 出力', + }, + }, + projectAutomations: { eyebrow: 'プロジェクト自動化', title: '自動化', subtitle: 'プロジェクト内の任意のサービスに対して SCM とスケジュール作業を作成します。Kanban はサービスヘッダーから有効化します。', loading: '自動化を読み込み中…', loadError: '自動化を読み込めませんでした。', new: '新しい自動化', noServices: '自動化を作成する前にサービスを接続してください。', diff --git a/console/src/i18n/locales/ko.ts b/console/src/i18n/locales/ko.ts index 07d887a3..acd1a73e 100644 --- a/console/src/i18n/locales/ko.ts +++ b/console/src/i18n/locales/ko.ts @@ -1337,6 +1337,9 @@ export default { conclusion: 'CI 결과', conclusionHint: '선택한 모든 이벤트가 check.completed인 경우에만 사용할 수 있습니다.', events: '이벤트', moreEvents: '추가 이벤트', thisProvider: '이 공급자', eventUnavailable: '{provider}{version}에서 지원하지 않습니다.', cronExpression: 'Cron 표현식', cronHint: '5개 필드 Cron 표현식을 사용합니다. 최소 간격은 서버에서 적용됩니다.', + outputMode: '출력', outputRun: 'Run 직접 생성', outputCard: 'jtype Card 생성 후 Service Kanban에서 실행', + outputHint: 'Card 출력은 jtype 작업 항목을 유지하고 표준 Kanban 접수 및 기록 흐름을 사용합니다.', + outputCardUnavailable: '정상 상태의 Service Kanban을 활성화한 후 Card 출력을 선택하세요.', create: '자동화 만들기', save: '자동화 저장', review: { eyebrow: 'GitHub 코드 리뷰', title: 'Pull Request 리뷰', subtitle: '팀이 새 흐름을 배우지 않아도 GitHub 네이티브 리뷰를 켤 수 있습니다.', @@ -1349,7 +1352,7 @@ export default { draftsTitle: 'Draft 포함', draftsBody: '보통 끄고 작성자의 첫 검토가 끝날 때까지 기다립니다.', create: '리뷰 켜기', save: '리뷰 설정 저장', }, - validation: { noPermission: '자동화를 편집할 권한이 없습니다.', required: '이름, 서비스, 모델 및 작업 프롬프트는 필수입니다.', eventRequired: '이 서비스 공급자가 지원하는 이벤트를 하나 이상 선택하세요.', cronRequired: 'Cron 표현식은 필수입니다.' }, + validation: { noPermission: '자동화를 편집할 권한이 없습니다.', required: '이름, 서비스, 모델 및 작업 프롬프트는 필수입니다.', eventRequired: '이 서비스 공급자가 지원하는 이벤트를 하나 이상 선택하세요.', cronRequired: 'Cron 표현식은 필수입니다.', cardOutputUnavailable: 'Card 출력에는 정상 상태의 Service Kanban 정책이 필요합니다.' }, apiError: { overlap: '선택한 SCM 이벤트 중 하나 이상을 이 서비스의 다른 자동화가 이미 사용하고 있습니다.', webhook: '자동화는 저장되었지만 공급자 Webhook을 동기화할 수 없습니다. 플러그인 연결을 확인하고 다시 시도하세요.', @@ -1361,6 +1364,34 @@ export default { }, }, + automationExecutions: { + loading: '자동화 불러오는 중…', loadError: '자동화를 불러오지 못했습니다.', back: '자동화 목록으로', + cardOutput: 'Card 출력', runOutput: '직접 Run 출력', runNow: '지금 실행', + runNowError: '실행을 접수하지 못했습니다. 재시도에도 같은 멱등성 키를 사용합니다.', + history: '실행 기록', loadingHistory: '실행 기록 불러오는 중…', historyError: '실행 기록을 불러오지 못했습니다.', + empty: '아직 실행 기록이 없습니다.', loadMore: '이전 실행 불러오기', select: '실행을 선택해 상세 내용을 확인하세요.', + selected: '선택한 실행', execution: '실행', trigger: '트리거', kind: '종류', + requested: '요청자', accountable: '책임자', notApplicable: '해당 없음', unattributed: '귀속 없음', + output: '출력', expected: '예상', cardThenRun: 'jtype Card → Kanban Run', directRun: '직접 Run', + actual: '실제', writeback: '기록', usage: '사용량', usageUnavailable: '사용 불가', + filter: { all: '전체', blocked: '차단됨', running: '실행 중', terminal: '종료됨' }, + state: { + accepted: '접수됨', ignored: '무시됨', duplicate: '중복', + superseded: '대체됨', blocked: '차단됨', queued: '대기 중', + running: '실행 중', terminal: '종료됨', + }, + outcome: { succeeded: '성공', failed: '실패', canceled: '취소됨' }, + repair: { + project_owner: '프로젝트 소유자가 이 종속성을 복구할 수 있습니다.', + cluster_admin: '클러스터 관리자가 이 종속성을 복구해야 합니다.', + requester: '요청자가 이 종속성을 복구해야 합니다.', + }, + title: { + blocked: '출력 전에 차단됨', ignored: '규칙에 따라 무시됨', + card: 'Card 출력', run: 'Run 출력', + }, + }, + projectAutomations: { eyebrow: '프로젝트 자동화', title: '자동화', subtitle: '프로젝트의 모든 서비스에 대해 SCM 및 예약 작업을 만듭니다. Kanban은 서비스 헤더에서 활성화합니다.', loading: '자동화 불러오는 중…', loadError: '자동화를 불러올 수 없습니다.', new: '새 자동화', noServices: '자동화를 만들기 전에 서비스를 연결하세요.', diff --git a/console/src/i18n/locales/zh-Hans.ts b/console/src/i18n/locales/zh-Hans.ts index 4529c9ab..5f6703a0 100644 --- a/console/src/i18n/locales/zh-Hans.ts +++ b/console/src/i18n/locales/zh-Hans.ts @@ -1343,6 +1343,9 @@ export default { conclusion: 'CI 结果', conclusionHint: '仅当所有选中事件均为 check.completed 时可用。', events: '事件', moreEvents: '更多事件', thisProvider: '当前提供方', eventUnavailable: '{provider}{version} 不支持此事件。', cronExpression: 'Cron 表达式', cronHint: '使用五段式 Cron 表达式;服务器会强制执行最小时间间隔。', + outputMode: '输出方式', outputRun: '直接创建 Run', outputCard: '先创建 jtype Card,再由服务看板执行', + outputHint: 'Card 输出会保留 jtype 工作项,并复用标准的看板受理与回写闭环。', + outputCardUnavailable: '请先启用健康的服务看板,再选择 Card 输出。', create: '创建自动化', save: '保存自动化', review: { eyebrow: 'GitHub 代码评审', title: '评审 Pull Request', subtitle: '无需让团队学习新流程,即可启用实用的 GitHub 原生评审。', @@ -1360,6 +1363,7 @@ export default { required: '名称、服务、模型和任务提示词均为必填项。', eventRequired: '请至少选择一个该服务提供方支持的事件。', cronRequired: 'Cron 表达式为必填项。', + cardOutputUnavailable: 'Card 输出需要健康的服务看板策略。', }, apiError: { overlap: '此服务的另一个自动化已经使用了所选 SCM 事件中的至少一个。', @@ -1372,6 +1376,34 @@ export default { }, }, + automationExecutions: { + loading: '正在加载自动化…', loadError: '无法加载自动化。', back: '返回自动化列表', + cardOutput: 'Card 输出', runOutput: '直接 Run 输出', runNow: '立即运行', + runNowError: '本次执行未能受理;重试会继续使用同一个幂等键。', + history: '执行历史', loadingHistory: '正在加载执行历史…', historyError: '无法加载执行历史。', + empty: '还没有执行记录。', loadMore: '加载更早的执行', select: '选择一条执行记录查看详情。', + selected: '选中的执行', execution: '执行', trigger: '触发来源', kind: '类型', + requested: '请求者', accountable: '责任人', notApplicable: '不适用', unattributed: '未归因', + output: '输出', expected: '预期', cardThenRun: 'jtype Card → 看板 Run', directRun: '直接 Run', + actual: '实际结果', writeback: '回写', usage: '用量', usageUnavailable: '暂不可用', + filter: { all: '全部', blocked: '受阻', running: '运行中', terminal: '已结束' }, + state: { + accepted: '已受理', ignored: '已忽略', duplicate: '重复', + superseded: '已取代', blocked: '受阻', queued: '排队中', + running: '运行中', terminal: '已结束', + }, + outcome: { succeeded: '成功', failed: '失败', canceled: '已取消' }, + repair: { + project_owner: '项目所有者可以修复此依赖。', + cluster_admin: '需要集群管理员修复此依赖。', + requester: '需要请求者修复此依赖。', + }, + title: { + blocked: '在生成输出前受阻', ignored: '触发已按规则忽略', + card: 'Card 输出', run: 'Run 输出', + }, + }, + projectAutomations: { eyebrow: '项目自动化', title: '自动化', subtitle: '针对项目中的任意服务创建 SCM 和定时任务。Kanban 从服务标题区域启用。', loading: '正在加载自动化…', loadError: '无法加载自动化。', new: '新建自动化', noServices: '创建自动化前请先连接服务。', diff --git a/console/src/i18n/locales/zh-Hant.ts b/console/src/i18n/locales/zh-Hant.ts index 3729faac..434b6239 100644 --- a/console/src/i18n/locales/zh-Hant.ts +++ b/console/src/i18n/locales/zh-Hant.ts @@ -1337,6 +1337,9 @@ export default { conclusion: 'CI 結果', conclusionHint: '僅當所有選取事件都是 check.completed 時可用。', events: '事件', moreEvents: '更多事件', thisProvider: '目前供應商', eventUnavailable: '{provider}{version} 不支援此事件。', cronExpression: 'Cron 表達式', cronHint: '使用五段式 Cron 表達式;伺服器會強制執行最小時間間隔。', + outputMode: '輸出方式', outputRun: '直接建立 Run', outputCard: '先建立 jtype Card,再由服務看板執行', + outputHint: 'Card 輸出會保留 jtype 工作項目,並沿用標準看板受理與回寫閉環。', + outputCardUnavailable: '請先啟用健康的服務看板,再選擇 Card 輸出。', create: '建立自動化', save: '儲存自動化', review: { eyebrow: 'GitHub 程式碼審查', title: '審查 Pull Request', subtitle: '無需讓團隊學習新流程,即可啟用實用的 GitHub 原生審查。', @@ -1349,7 +1352,7 @@ export default { draftsTitle: '包含草稿', draftsBody: '通常保持關閉,讓作者先完成自己的第一輪檢查。', create: '開啟審查', save: '儲存審查設定', }, - validation: { noPermission: '你沒有編輯自動化的權限。', required: '名稱、服務、模型與任務提示詞都是必填項目。', eventRequired: '請至少選擇一個此服務供應商支援的事件。', cronRequired: 'Cron 表達式為必填項目。' }, + validation: { noPermission: '你沒有編輯自動化的權限。', required: '名稱、服務、模型與任務提示詞都是必填項目。', eventRequired: '請至少選擇一個此服務供應商支援的事件。', cronRequired: 'Cron 表達式為必填項目。', cardOutputUnavailable: 'Card 輸出需要健康的服務看板策略。' }, apiError: { overlap: '此服務的另一個自動化已使用所選 SCM 事件中的至少一個。', webhook: '自動化已儲存,但無法同步供應商 Webhook。請檢查外掛連接後重試。', @@ -1361,6 +1364,34 @@ export default { }, }, + automationExecutions: { + loading: '正在載入自動化…', loadError: '無法載入自動化。', back: '返回自動化列表', + cardOutput: 'Card 輸出', runOutput: '直接 Run 輸出', runNow: '立即執行', + runNowError: '本次執行未能受理;重試會沿用同一個冪等鍵。', + history: '執行歷史', loadingHistory: '正在載入執行歷史…', historyError: '無法載入執行歷史。', + empty: '尚無執行記錄。', loadMore: '載入更早的執行', select: '選擇一筆執行記錄查看詳情。', + selected: '選取的執行', execution: '執行', trigger: '觸發來源', kind: '類型', + requested: '請求者', accountable: '負責人', notApplicable: '不適用', unattributed: '未歸因', + output: '輸出', expected: '預期', cardThenRun: 'jtype Card → 看板 Run', directRun: '直接 Run', + actual: '實際結果', writeback: '回寫', usage: '用量', usageUnavailable: '暫不可用', + filter: { all: '全部', blocked: '受阻', running: '執行中', terminal: '已結束' }, + state: { + accepted: '已受理', ignored: '已忽略', duplicate: '重複', + superseded: '已取代', blocked: '受阻', queued: '排隊中', + running: '執行中', terminal: '已結束', + }, + outcome: { succeeded: '成功', failed: '失敗', canceled: '已取消' }, + repair: { + project_owner: '專案擁有者可以修復此依賴。', + cluster_admin: '需要叢集管理員修復此依賴。', + requester: '需要請求者修復此依賴。', + }, + title: { + blocked: '在產生輸出前受阻', ignored: '觸發已依規則忽略', + card: 'Card 輸出', run: 'Run 輸出', + }, + }, + projectAutomations: { eyebrow: '專案自動化', title: '自動化', subtitle: '針對專案中的任何服務建立 SCM 與排程工作。Kanban 從服務標題區域啟用。', loading: '正在載入自動化…', loadError: '無法載入自動化。', new: '新增自動化', noServices: '建立自動化前請先連接服務。', diff --git a/console/src/pages/AutomationDetailPage.module.css b/console/src/pages/AutomationDetailPage.module.css new file mode 100644 index 00000000..f1573c14 --- /dev/null +++ b/console/src/pages/AutomationDetailPage.module.css @@ -0,0 +1,53 @@ +.page { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: var(--space-6) 0 var(--space-12); } +.back { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--color-text-faint); font-size: var(--fs-sm); text-decoration: none; } +.head { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-6); margin: var(--space-6) 0 var(--space-5); } +.head > div:first-child > span, .inspector header span { color: var(--color-accent-dim); font-family: var(--font-mono); font-size: var(--fs-tiny); font-weight: var(--fw-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; } +.head h1 { margin: var(--space-2) 0 4px; color: var(--color-text); font-size: clamp(28px, 4vw, 42px); letter-spacing: var(--tracking-tight); } +.head p { color: var(--color-text-dim); } +.headActions { display: flex; align-items: center; gap: var(--space-3); } +.headActions > a { color: var(--color-text-muted); font-size: var(--fs-sm); } +.actionError { margin-bottom: var(--space-4); color: var(--color-danger); font-size: var(--fs-sm); } +.layout { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .8fr); overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-2xl); background: var(--color-panel); box-shadow: var(--shadow-1); } +.ledger { min-width: 0; } +.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-4); border-bottom: 1px solid var(--color-border); } +.filters { display: flex; gap: 3px; padding: 2px; border-radius: var(--radius-lg); background: var(--color-bg-inset); } +.filters button { min-height: 28px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-text-faint); font-size: var(--fs-caption); } +.filters button[aria-pressed='true'] { background: var(--color-panel); color: var(--color-text); box-shadow: var(--shadow-1); } +.list { margin: 0; padding: 0; list-style: none; } +.list li > button { display: grid; width: 100%; grid-template-columns: 96px minmax(0, 1fr) auto; gap: var(--space-3); padding: var(--space-4); border: 0; border-bottom: 1px solid var(--color-border); background: transparent; color: inherit; text-align: left; } +.list li > button:hover, .list li > button.selected { background: color-mix(in srgb, var(--color-accent) 6%, var(--color-panel)); } +.list li > button.selected { box-shadow: inset 3px 0 var(--color-accent); } +.list strong, .list small { display: block; } +.list small { margin-top: 4px; overflow: hidden; color: var(--color-text-faint); font-size: var(--fs-caption); text-overflow: ellipsis; white-space: nowrap; } +.list time { color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); white-space: nowrap; } +.state { align-self: start; width: max-content; padding: 2px 7px; border: 1px solid var(--color-border); border-radius: var(--radius-pill); color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--fs-tiny); font-weight: var(--fw-semibold); } +.blocked { border-color: color-mix(in srgb, var(--color-warning) 55%, var(--color-border)); color: var(--color-warning); } +.running, .queued, .accepted { border-color: color-mix(in srgb, var(--color-accent) 55%, var(--color-border)); color: var(--color-accent-dim); } +.ignored, .superseded, .duplicate { color: var(--color-text-faint); } +.empty { padding: var(--space-8); color: var(--color-text-faint); text-align: center; } +.more { width: 100%; padding: var(--space-3); border: 0; background: transparent; color: var(--color-accent-dim); } +.inspector { min-width: 0; border-left: 1px solid var(--color-border); background: var(--color-bg-inset); } +.inspector header, .inspector section { padding: var(--space-5); border-bottom: 1px solid var(--color-border); } +.inspector h2 { margin-top: var(--space-2); color: var(--color-text); font-size: var(--fs-h2); } +.inspector h3 { margin-bottom: var(--space-3); color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); letter-spacing: var(--tracking-label); text-transform: uppercase; } +.inspector dl { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: var(--space-2) var(--space-3); margin: 0; font-size: var(--fs-sm); } +.inspector dt { color: var(--color-text-faint); } +.inspector dd { margin: 0; overflow-wrap: anywhere; color: var(--color-text-muted); } +.inspector dd a { display: inline-flex; align-items: center; gap: 4px; color: var(--color-accent-dim); } +.repair { display: flex; align-items: flex-start; gap: var(--space-2); margin-top: var(--space-4); padding: var(--space-3); border-left: 2px solid var(--color-warning); background: color-mix(in srgb, var(--color-warning) 8%, var(--color-panel)); color: var(--color-text-muted); font-size: var(--fs-caption); line-height: 1.5; } +.repair strong, .repair small { display: block; } +.repair strong { color: var(--color-warning); font-family: var(--font-mono); font-size: var(--fs-tiny); } +@media (max-width: 860px) { + .page { width: min(100% - 24px, 680px); } + .head { align-items: flex-start; flex-direction: column; } + .layout { grid-template-columns: 1fr; } + .inspector { border-top: 1px solid var(--color-border); border-left: 0; } +} +@media (max-width: 600px) { + .toolbar { align-items: flex-start; flex-direction: column; } + .filters { max-width: 100%; overflow-x: auto; } + .list li > button { grid-template-columns: 1fr auto; } + .list li > button > span:nth-child(2) { grid-column: 1 / -1; grid-row: 2; } + .state { grid-column: 1; grid-row: 1; } + .list time { grid-column: 2; grid-row: 1; } +} diff --git a/console/src/pages/AutomationDetailPage.test.tsx b/console/src/pages/AutomationDetailPage.test.tsx new file mode 100644 index 00000000..18811a28 --- /dev/null +++ b/console/src/pages/AutomationDetailPage.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiProvider } from '../api/ApiProvider'; +import type { ApiClient } from '../api/client'; +import type { AutomationExecution, Project, ProjectAutomationSpec } from '../api/types'; +import { AutomationDetailPage } from './AutomationDetailPage'; + +const spec: ProjectAutomationSpec = { + automation: { + id: 'auto-1', + service_id: 'svc-1', + name: 'Weekday issue sweep', + trigger_kind: 'cron', + prompt_template: 'Triage issues', + enabled: true, + ignore_jcode: true, + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + }, + cron: { cron_expr: '0 9 * * 1-5', output_mode: 'create_card' }, +}; + +const blocked: AutomationExecution = { + id: 'execution-blocked', + automation_id: 'auto-1', + automation_name: spec.automation.name, + trigger_kind: 'cron', + state: 'blocked', + output_mode: 'create_card', + reason_code: 'jtype_unavailable', + reason: 'Reconnect the Project JType plugin.', + repair_role: 'project_owner', + requested_actor: null, + accountable_actor: { kind: 'cloud_user', id: 'owner', label: 'Ada' }, + output: { kind: 'none', label: 'No output', available: false }, + run: null, + card: null, + writeback_state: '', + usage: { state: 'unavailable' }, + created_at: '2026-07-31T09:00:00Z', + updated_at: '2026-07-31T09:00:00Z', +}; + +function renderPage(options: { + role?: 'owner' | 'member' | 'viewer'; + items?: AutomationExecution[]; + runAutomationNow?: ApiClient['runAutomationNow']; +} = {}) { + const project: Project = { + id: 'p1', name: 'Payments', role: options.role ?? 'member', created_at: '', + services: [{ + id: 'svc-1', project_id: 'p1', name: 'API', repo_kind: 'raw', + raw_repo_url: 'https://example.invalid/repo.git', default_branch: 'main', + git_mode: 'readonly', created_at: '', + }], + }; + const client = { + getProject: async () => project, + getProjectAutomation: async () => spec, + listAutomationExecutions: async () => ({ items: options.items ?? [blocked], next_cursor: null }), + runAutomationNow: options.runAutomationNow ?? vi.fn(async () => blocked), + } as unknown as ApiClient; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + + + + + } /> + + + + , + ); +} + +describe('AutomationDetailPage', () => { + it('keeps blocked reason, accountability, output, and unavailable usage distinct', async () => { + renderPage(); + expect(await screen.findByRole('heading', { name: 'Weekday issue sweep' })).toBeTruthy(); + expect(screen.getAllByText('Blocked before output').length).toBeGreaterThan(0); + expect(screen.getAllByText('Reconnect the Project JType plugin.')).toHaveLength(2); + expect(screen.getByText('Ada')).toBeTruthy(); + expect(screen.getByText('jtype Card → Kanban Run')).toBeTruthy(); + expect(screen.getByText('Unavailable')).toBeTruthy(); + expect(screen.queryByText('0 tokens')).toBeNull(); + }); + + it('keeps the same idempotency key when Run now is retried after a network failure', async () => { + const calls: string[] = []; + const runNow = vi.fn(async (_automationId: string, key: string) => { + calls.push(key); + if (calls.length === 1) throw new Error('network'); + return blocked; + }); + renderPage({ runAutomationNow: runNow }); + const button = await screen.findByRole('button', { name: 'Run now' }); + fireEvent.click(button); + await screen.findByRole('alert'); + fireEvent.click(button); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[0]).toBe(calls[1]); + }); + + it('lets a Viewer inspect history but not trigger a new execution', async () => { + renderPage({ role: 'viewer' }); + const button = await screen.findByRole('button', { name: 'Run now' }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(screen.getAllByText('Reconnect the Project JType plugin.')).toHaveLength(2); + }); +}); diff --git a/console/src/pages/AutomationDetailPage.tsx b/console/src/pages/AutomationDetailPage.tsx new file mode 100644 index 00000000..63b9dac3 --- /dev/null +++ b/console/src/pages/AutomationDetailPage.tsx @@ -0,0 +1,207 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { ArrowLeft, ArrowSquareOut, Play, Wrench } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '../components/Button'; +import { ErrorBlock, LoadingBlock } from '../components/States'; +import { + useAutomationExecutions, + useProject, + useProjectAutomation, + useRunAutomationNow, +} from '../api/queries'; +import type { AutomationExecution, AutomationExecutionState } from '../api/types'; +import styles from './AutomationDetailPage.module.css'; + +type Filter = '' | 'blocked' | 'running' | 'terminal'; + +export function AutomationDetailPage() { + const { t } = useTranslation(); + const { projectId = '', automationId = '' } = useParams(); + const project = useProject(projectId); + const automation = useProjectAutomation(projectId, automationId); + const [filter, setFilter] = useState(''); + const executions = useAutomationExecutions(automationId, filter); + const runNow = useRunAutomationNow(automationId); + const retryKey = useRef(''); + const [selectedId, setSelectedId] = useState(''); + const items = useMemo( + () => executions.data?.pages.flatMap((page) => page.items) ?? [], + [executions.data], + ); + const selected = items.find((item) => item.id === selectedId) ?? items[0]; + const canRun = project.data?.role !== 'viewer'; + + useEffect(() => { + if (!selectedId && items[0]) setSelectedId(items[0].id); + }, [items, selectedId]); + + if (project.isLoading || automation.isLoading) { + return ; + } + if (project.isError || automation.isError) { + return ; + } + const spec = automation.data; + if (!spec) return null; + const service = project.data?.services?.find((item) => item.id === spec.automation.service_id); + + const triggerSummary = spec.scm + ? (spec.actions ?? []).map((action) => `${action.event_family}.${action.action}`).join(', ') + : `${spec.cron?.cron_expr ?? ''} · ${spec.cron?.output_mode === 'create_card' + ? t('automationExecutions.cardOutput') + : t('automationExecutions.runOutput')}`; + + const triggerNow = () => { + if (!retryKey.current) retryKey.current = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; + runNow.mutate(retryKey.current, { + onSuccess: (value) => { + retryKey.current = ''; + setSelectedId(value.id); + }, + }); + }; + + return ( +
+ + {t('automationExecutions.back')} + +
+
+ {spec.automation.trigger_kind} · {spec.automation.enabled + ? t('projectAutomations.enabled') : t('projectAutomations.disabled')} +

{spec.automation.name}

+

{service?.name ?? spec.automation.service_id} · {triggerSummary}

+
+
+ + {t('projectAutomations.edit')} + + +
+
+ {runNow.error &&

{t('automationExecutions.runNowError')}

} + +
+
+
+ {t('automationExecutions.history')} +
+ {(['', 'blocked', 'running', 'terminal'] as Filter[]).map((value) => ( + + ))} +
+
+ {executions.isLoading ? ( + + ) : executions.isError ? ( + void executions.refetch()} title={t('automationExecutions.historyError')} /> + ) : !items.length ? ( +

{t('automationExecutions.empty')}

+ ) : ( + <> +
    + {items.map((item) => ( +
  1. + +
  2. + ))} +
+ {executions.hasNextPage && ( + + )} + + )} +
+ +
+
+ ); +} + +function StateBadge({ state, outcome }: { state: AutomationExecutionState; outcome?: string }) { + const { t } = useTranslation(); + const label = state === 'terminal' && outcome + ? t(`automationExecutions.outcome.${outcome}`) + : t(`automationExecutions.state.${state}`); + return {label}; +} + +function ExecutionInspector({ execution }: { execution?: AutomationExecution }) { + const { t } = useTranslation(); + if (!execution) { + return ; + } + return ( + + ); +} + +function executionTitle(item: AutomationExecution, t: (key: string) => string): string { + if (item.state === 'blocked') return t('automationExecutions.title.blocked'); + if (item.state === 'ignored') return t('automationExecutions.title.ignored'); + if (item.output.kind === 'card') return t('automationExecutions.title.card'); + if (item.output.kind === 'run') return t('automationExecutions.title.run'); + return t('automationExecutions.execution'); +} diff --git a/console/src/pages/AutomationEditorPage.test.tsx b/console/src/pages/AutomationEditorPage.test.tsx index a6365334..a9e6b3f8 100644 --- a/console/src/pages/AutomationEditorPage.test.tsx +++ b/console/src/pages/AutomationEditorPage.test.tsx @@ -154,6 +154,51 @@ describe('AutomationEditorPage', () => { expect(create).not.toHaveBeenCalled(); }); + it('enables Card output only when Service Kanban is healthy and sends the explicit mode', async () => { + const { create } = renderEditor({ + getServiceKanbanPolicy: async () => ({ + service_id: 'svc-1', + service_name: 'API', + repository: 'acme/api', + model: { id: 'glm-52', label: 'GLM 5.2' }, + board: { workspace_id: 'ws', ref: 'board' }, + trigger_column: { key: 'agent', label: 'Agent' }, + done_column: {}, + output: 'comment_only', + health: { state: 'ready', blocker: null }, + }), + }); + await screen.findByRole('heading', { name: 'Create Automation' }); + fireEvent.click(screen.getByRole('button', { name: 'Cron' })); + await waitFor(() => { + const option = screen.getByRole('option', { name: /jtype Card/ }) as HTMLOptionElement; + expect(option.disabled).toBe(false); + }); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Issue sweep' } }); + fireEvent.change(screen.getByLabelText('Model'), { target: { value: 'glm-52' } }); + fireEvent.change(screen.getByLabelText('Prompt template'), { target: { value: 'Triage open issues' } }); + fireEvent.change(screen.getByLabelText('Output'), { target: { value: 'create_card' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create Automation' })); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + expect(create.mock.calls[0]?.[1].cron).toEqual({ + cron_expr: '0 9 * * 1-5', + output_mode: 'create_card', + }); + }); + + it('keeps Card output visibly unavailable without a healthy Service Kanban policy', async () => { + renderEditor({ + getServiceKanbanPolicy: async () => { + throw new Error('not configured'); + }, + }); + await screen.findByRole('heading', { name: 'Create Automation' }); + fireEvent.click(screen.getByRole('button', { name: 'Cron' })); + const option = await screen.findByRole('option', { name: /jtype Card/ }) as HTMLOptionElement; + expect(option.disabled).toBe(true); + expect(screen.getByText('Enable a healthy Service Kanban policy before selecting Card output.')).toBeTruthy(); + }); + it('keeps common SCM events visible and places the complete low-frequency matrix behind More events', async () => { renderEditor(); await screen.findByRole('heading', { name: 'Create Automation' }); diff --git a/console/src/pages/AutomationEditorPage.tsx b/console/src/pages/AutomationEditorPage.tsx index d12d3a30..b9bfd26d 100644 --- a/console/src/pages/AutomationEditorPage.tsx +++ b/console/src/pages/AutomationEditorPage.tsx @@ -11,6 +11,7 @@ import { useProjectAutomation, useProjectModels, useProviderCapabilities, + useServiceKanbanPolicy, useUpdateProjectAutomation, } from '../api/queries'; import type { @@ -124,6 +125,7 @@ export function AutomationEditorPage() { const [includeDrafts, setIncludeDrafts] = useState(false); const [showMoreActions, setShowMoreActions] = useState(false); const [cronExpr, setCronExpr] = useState('0 9 * * 1-5'); + const [cronOutputMode, setCronOutputMode] = useState<'run_only' | 'create_card'>('run_only'); const [formError, setFormError] = useState(''); const selectedService = services.find((service) => service.id === serviceId); @@ -136,6 +138,8 @@ export function AutomationEditorPage() { selectedService?.provider === 'gitea' ) ? selectedService.provider as ProviderKind : 'github'; const capabilities = useProviderCapabilities(scmProvider, kind === 'scm' && !!selectedService); + const kanbanPolicy = useServiceKanbanPolicy(serviceId, kind === 'cron' && !!serviceId); + const cardOutputReady = kanbanPolicy.data?.health.state === 'ready'; const supported = useMemo(() => { if (!capabilities.data) return supportedActions(selectedService?.provider); return new Set(capabilities.data.capabilities.flatMap((group) => @@ -203,6 +207,7 @@ export function AutomationEditorPage() { setIncludeDrafts(spec.scm?.include_drafts ?? false); setActions((spec.actions ?? []).map(actionName)); setCronExpr(spec.cron?.cron_expr ?? '0 9 * * 1-5'); + setCronOutputMode(spec.cron?.output_mode ?? 'run_only'); } function toggleAction(action: NormalizedScmAction) { @@ -250,6 +255,10 @@ export function AutomationEditorPage() { setFormError(t('automationEditor.validation.cronRequired')); return; } + if (kind === 'cron' && cronOutputMode === 'create_card' && !cardOutputReady) { + setFormError(t('automationEditor.validation.cardOutputUnavailable')); + return; + } const input: CreateProjectAutomationInput = { service_id: serviceId, name: name.trim(), @@ -270,7 +279,7 @@ export function AutomationEditorPage() { actions: actions.filter((action) => supported.has(action)).map(actionParts), }, } : {}), - ...(kind === 'cron' ? { cron: { cron_expr: cronExpr.trim() } } : {}), + ...(kind === 'cron' ? { cron: { cron_expr: cronExpr.trim(), output_mode: cronOutputMode } } : {}), }; const onSuccess = () => navigate(`/projects/${encodeURIComponent(projectId)}?tab=automations&service=${encodeURIComponent(serviceId)}`); if (editing) update.mutate({ automationId, input }, { onSuccess }); @@ -506,6 +515,15 @@ export function AutomationEditorPage() {
{t('automationEditor.cronHint')} + + {!cardOutputReady + ? kanbanPolicy.data?.health.blocker ?? t('automationEditor.outputCardUnavailable') + : t('automationEditor.outputHint')}
)} } diff --git a/console/src/pages/KanbanBoardModal.test.tsx b/console/src/pages/KanbanBoardModal.test.tsx index 5e62b4ce..24b90197 100644 --- a/console/src/pages/KanbanBoardModal.test.tsx +++ b/console/src/pages/KanbanBoardModal.test.tsx @@ -19,6 +19,8 @@ vi.mock('jtype-board-react', () => ({ boardRef: string; live?: boolean; readOnly?: boolean; + initialCardPath?: string; + additionalCardRoots?: readonly string[]; renderCardSupplement?: (card: { id: string; title: string }) => ReactNode; }) => ( <> @@ -28,6 +30,8 @@ vi.mock('jtype-board-react', () => ({ data-boardref={p.boardRef} data-live={String(p.live)} data-readonly={String(p.readOnly)} + data-initial-card={p.initialCardPath} + data-additional-card-roots={p.additionalCardRoots?.join(',')} /> {p.renderCardSupplement?.({ id: 'cards/payment.md', title: 'Payment card' })} @@ -167,7 +171,20 @@ describe('KanbanBoardModal', () => { it('single link: renders the board with the workspace and live=false', async () => { const api = makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }); - renderModal(api, [link()]); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + {}} + /> + + , + ); // The board is a working surface, not a form: it opts into Modal's bounded // wide layout so horizontal board scrolling stays inside the dialog. @@ -176,10 +193,21 @@ describe('KanbanBoardModal', () => { expect(board.getAttribute('data-workspace')).toBe('ws_team'); // No SSE proxy → the board is handed live=false (visible polling). expect(board.getAttribute('data-live')).toBe('false'); + expect(board.getAttribute('data-readonly')).toBe('false'); + expect(board.getAttribute('data-initial-card')).toBe('cards/payment.md'); + expect(board.getAttribute('data-additional-card-roots')).toBe('jcode-automation'); // Single link → no selector. expect(screen.queryByTestId('kanban-board-select')).toBeNull(); }); + it('makes the embedded board read-only when the caller cannot manage Cards', async () => { + const api = makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }); + renderModal(api, [link()]); + + const board = await screen.findByTestId('jtype-board'); + expect(board.getAttribute('data-readonly')).toBe('true'); + }); + 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 }, diff --git a/console/src/pages/KanbanBoardModal.tsx b/console/src/pages/KanbanBoardModal.tsx index 9824e02a..850ccc4b 100644 --- a/console/src/pages/KanbanBoardModal.tsx +++ b/console/src/pages/KanbanBoardModal.tsx @@ -43,6 +43,8 @@ import { resolveBoardPathById } from '../kanban/resolveBoardPathById'; import type { BoardEmbedLink, KanbanCardExecution, PluginBoardResource } from '../api/types'; import styles from './KanbanBoardModal.module.css'; +const CLOUD_MANAGED_CARD_ROOTS = ['jcode-automation'] as const; + /** Map the browser locale to a board-supported one; default 'en'. */ function boardLocale(): BoardLocale { const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en') @@ -128,6 +130,7 @@ interface Props { projectId: string; serviceId?: string; links: BoardEmbedLink[]; + initialCardPath?: string; canManage?: boolean; onClose: () => void; } @@ -299,7 +302,14 @@ function CardExecutionsSupplement({ ); } -export function KanbanBoardModal({ projectId, serviceId = '', links, canManage = false, onClose }: Props) { +export function KanbanBoardModal({ + projectId, + serviceId = '', + links, + initialCardPath, + canManage = false, + onClose, +}: Props) { const { t } = useTranslation(); const api = useApi(); // Memoize the injected client: a new identity per render restarts the board. @@ -571,7 +581,10 @@ export function KanbanBoardModal({ projectId, serviceId = '', links, canManage = client={proxyClient} workspaceId={link.workspace_id} boardRef={resolved.data} + initialCardPath={initialCardPath} + additionalCardRoots={CLOUD_MANAGED_CARD_ROOTS} live={false} + readOnly={!canManage} locale={boardLocale()} renderCardSupplement={serviceId ? (card) => ( ({ + KanbanBoardModal: (props: { + initialCardPath?: string; + canManage?: boolean; + onClose: () => void; + }) => ( +
+ +
+ ), +})); + import { ProjectDetailPage } from './ProjectDetailPage'; function svc(id: string, name: string): Service { @@ -667,6 +683,37 @@ describe('ProjectDetailPage — multi-repo workspace', () => { }); describe('ProjectDetailPage — workspace sections', () => { + it('opens an exact Automation Card deep link read-only for a Viewer and canonicalizes the URL', async () => { + const { client } = makeClient(project('viewer', [svc('svc_default', 'default')])); + const listProjectBoardLinks = vi.fn(async () => [{ + id: 'kanban-1', + workspace_id: 'workspace-1', + board_ref: 'board-1', + board_title: 'Delivery', + service_id: 'svc_default', + trigger_column: 'agent', + enabled: true, + }]); + (client as { listProjectBoardLinks?: unknown }).listProjectBoardLinks = listProjectBoardLinks; + renderPage( + client, + undefined, + '/projects/p1?service=svc_default&tab=automations&kanban=1&card=jcode-automation%2Fauto-1%2Fexec-1.md', + ); + + const modal = await screen.findByTestId('kanban-modal-stub'); + expect(modal.getAttribute('data-initial-card')).toBe('jcode-automation/auto-1/exec-1.md'); + expect(modal.getAttribute('data-can-manage')).toBe('false'); + expect(listProjectBoardLinks).toHaveBeenCalledWith('p1'); + await waitFor(() => { + const search = screen.getByTestId('workspace-location').textContent ?? ''; + expect(search).toContain('service=svc_default'); + expect(search).toContain('tab=automations'); + expect(search).not.toContain('kanban='); + expect(search).not.toContain('card='); + }); + }); + it('renders the unified Project Automation list without legacy schedule surfaces', async () => { const { client } = makeClient(project('owner', [svc('svc_default', 'default')]), { projectAutomations: [{ diff --git a/console/src/pages/ProjectDetailPage.tsx b/console/src/pages/ProjectDetailPage.tsx index 08ff56e4..0f84e46f 100644 --- a/console/src/pages/ProjectDetailPage.tsx +++ b/console/src/pages/ProjectDetailPage.tsx @@ -91,6 +91,7 @@ export function ProjectDetailPage() { const [askApproval, setAskApproval] = useState(false); const [runFilter, setRunFilter] = useState('all'); const [kanbanOpen, setKanbanOpen] = useState(false); + const [kanbanCardPath, setKanbanCardPath] = useState(''); const [repoQuery, setRepoQuery] = useState(''); const [pickerInstallationId, setPickerInstallationId] = useState(''); const deferredQuery = useDeferredValue(repoQuery); @@ -112,6 +113,16 @@ export function ProjectDetailPage() { const projectSettingsOpen = canManage && searchParams.get('view') === 'project-settings'; const projectSettingsSection = resolveProjectSettingsSection(searchParams.get('settings'), canManage); + useEffect(() => { + if (searchParams.get('kanban') !== '1' || !activeService) return; + setKanbanCardPath(searchParams.get('card') ?? ''); + setKanbanOpen(true); + const next = new URLSearchParams(searchParams); + next.delete('kanban'); + next.delete('card'); + setSearchParams(next, { replace: true }); + }, [activeService, searchParams, setSearchParams]); + // A project switch must not retain a previous project's draft/model/form state. useEffect(() => { setPrompt(''); @@ -166,7 +177,9 @@ export function ProjectDetailPage() { }, [p, projectSettingsOpen, projectSettingsSection, searchParams, setSearchParams, workspaceLocation]); const pluginsQuery = useProjectPlugins(projectId, !!p && canRun); - const boardLinks = useProjectBoardLinks(projectId, !!p && canRun); + // Viewers cannot open Kanban manually, but may follow an Automation output + // deep link into the same board with the host enforcing read-only access. + const boardLinks = useProjectBoardLinks(projectId, !!p); const activeBoardLinks = (boardLinks.data ?? []).filter((link) => link.service_id === activeService?.id); const jtypePluginEnabled = (pluginsQuery.data ?? []).some((plugin) => plugin.provider === 'jtype' && plugin.status === 'enabled' && !!plugin.workspace_id); @@ -808,8 +821,12 @@ export function ProjectDetailPage() { projectId={projectId} serviceId={activeService.id} links={activeBoardLinks} + initialCardPath={kanbanCardPath || undefined} canManage={canRun} - onClose={() => setKanbanOpen(false)} + onClose={() => { + setKanbanOpen(false); + setKanbanCardPath(''); + }} /> )} diff --git a/console/src/project-workspace/ProjectAutomationsPanel.tsx b/console/src/project-workspace/ProjectAutomationsPanel.tsx index e6e1ad6c..c7cef2df 100644 --- a/console/src/project-workspace/ProjectAutomationsPanel.tsx +++ b/console/src/project-workspace/ProjectAutomationsPanel.tsx @@ -126,7 +126,11 @@ export function ProjectAutomationsPanel({ return (
  • - {automation.name} + + + {automation.name} + +

    {automation.run_kind === 'review' ? t('projectAutomations.review.rowSummary') : automation.prompt_template}

    diff --git a/design/assets/automation-executions.css b/design/assets/automation-executions.css new file mode 100644 index 00000000..45761586 --- /dev/null +++ b/design/assets/automation-executions.css @@ -0,0 +1,46 @@ +.ae-page { min-height: 100vh; background: var(--bg); color: var(--ink); } +.ae-shell { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 56px; } +.ae-back { color: var(--muted); font-size: 13px; text-decoration: none; } +.ae-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin: 24px 0 22px; } +.ae-kicker { color: var(--accent); font: 700 11px/1.2 var(--mono); letter-spacing: .12em; text-transform: uppercase; } +.ae-head h1 { margin: 7px 0 5px; font-size: clamp(28px, 4vw, 42px); letter-spacing: -.035em; } +.ae-head p { margin: 0; color: var(--muted); } +.ae-actions { display: flex; gap: 9px; } +.ae-button { min-height: 38px; padding: 0 15px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--ink); font-weight: 650; } +.ae-button.primary { border-color: var(--accent); background: var(--accent); color: white; } +.ae-layout { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .8fr); border: 1px solid var(--border); border-radius: 18px; overflow: hidden; background: var(--panel); box-shadow: var(--shadow); } +.ae-ledger { min-width: 0; } +.ae-toolbar { display: flex; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--border); } +.ae-filters { display: flex; gap: 4px; } +.ae-filter { padding: 6px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); } +.ae-filter.active { background: var(--wash); color: var(--ink); } +.ae-list { list-style: none; margin: 0; padding: 0; } +.ae-row { display: grid; grid-template-columns: 104px minmax(0, 1fr) auto; gap: 15px; padding: 16px 18px; border-bottom: 1px solid var(--border); cursor: pointer; } +.ae-row.selected { background: color-mix(in srgb, var(--accent) 7%, var(--panel)); box-shadow: inset 3px 0 var(--accent); } +.ae-state { align-self: start; width: max-content; padding: 3px 8px; border: 1px solid var(--border); border-radius: 999px; font: 700 11px/1.4 var(--mono); } +.ae-state.blocked { border-color: #d97706; color: #9a4e05; } +.ae-state.running { border-color: var(--accent); color: var(--accent); } +.ae-row strong, .ae-row small { display: block; } +.ae-row small { margin-top: 5px; color: var(--muted); line-height: 1.45; } +.ae-time { color: var(--muted); font: 12px/1.4 var(--mono); white-space: nowrap; } +.ae-inspector { border-left: 1px solid var(--border); background: var(--wash); } +.ae-inspector header, .ae-inspector section { padding: 18px 20px; border-bottom: 1px solid var(--border); } +.ae-inspector h2 { margin: 4px 0 0; font-size: 20px; } +.ae-inspector h3 { margin: 0 0 12px; color: var(--muted); font: 700 11px/1.2 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +.ae-inspector dl { display: grid; grid-template-columns: 98px minmax(0, 1fr); gap: 9px 12px; margin: 0; font-size: 13px; } +.ae-inspector dt { color: var(--muted); } +.ae-inspector dd { margin: 0; overflow-wrap: anywhere; } +.ae-repair { margin-top: 13px; padding: 10px 12px; border-left: 2px solid #d97706; background: #fff8ea; color: #754006; font-size: 12px; line-height: 1.5; } +@media (max-width: 820px) { + .ae-shell { width: min(100% - 24px, 680px); } + .ae-head { align-items: start; flex-direction: column; } + .ae-layout { grid-template-columns: 1fr; } + .ae-inspector { border-top: 1px solid var(--border); border-left: 0; } +} +@media (max-width: 560px) { + .ae-toolbar { align-items: start; flex-direction: column; } + .ae-row { grid-template-columns: 1fr auto; } + .ae-row > div:nth-child(2) { grid-column: 1 / -1; grid-row: 2; } + .ae-state { grid-row: 1; } + .ae-time { grid-column: 2; grid-row: 1; } +} diff --git a/design/automation-executions.html b/design/automation-executions.html new file mode 100644 index 00000000..393c5978 --- /dev/null +++ b/design/automation-executions.html @@ -0,0 +1,78 @@ + + + + + + Automation executions · jcode Cloud + + + + +
    +
    + ← Payments API / Automations +
    +
    + Cron · enabled +

    Weekday issue sweep

    +

    At 09:00 UTC · creates a Card for the Payments API agent queue

    +
    +
    + + +
    +
    +
    +
    +
    + Execution history +
    + + + +
    +
    +
      +
    1. + Blocked +
      Scheduled fire · Card outputJType connection needs attention. No direct Run was created.
      + +
    2. +
    3. + Running +
      Card #payments-184 → Run 8F2AAgent is working; accepted receipt written to jtype.
      + +
    4. +
    5. + Succeeded +
      Card #payments-179 → Run 76D1Card comment written and moved to Done.
      + +
    6. +
    7. + Ignored +
      SCM event did not match filtersRecorded as ignored; it is not a successful Run.
      + +
    8. +
    +
    + +
    +
    +
    + + diff --git a/design/index.html b/design/index.html index 2eefc56c..72fb4341 100644 --- a/design/index.html +++ b/design/index.html @@ -33,6 +33,11 @@

    One surface per file.

    Run provenance & Bot identityRequested, accountable, runtime, trigger and writeback identities + + EX + Automation execution ledgerTrigger decisions, outputs, blockers, repair actions and Run now + + CR Pull request reviewsOne-click setup, real App mention, and visible review workflow diff --git a/docs/23-kanban-execution-receipts.md b/docs/23-kanban-execution-receipts.md index d0970c68..6d4b95bd 100644 --- a/docs/23-kanban-execution-receipts.md +++ b/docs/23-kanban-execution-receipts.md @@ -304,9 +304,11 @@ type JTypeBoardProps = { } ``` -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. +The function is invoked for the built-in editable and read-only Card details +and its result is rendered in a dedicated, labelled-neutral slot beneath native +Card fields. It is not invoked when `onCardOpen` intercepts the detail. This +lets a Viewer inspect Cloud execution history without exposing mutation +affordances. Shared board components receive the value through slots: @@ -325,7 +327,7 @@ are part of the release. Feature impact: | 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 | +| read-only embed | Optional read-only supplement in native detail | | intercepted `onCardOpen` | Unchanged; host owns the detail | ## Embedded UX contract diff --git a/docs/25-automation-execution-ledger.md b/docs/25-automation-execution-ledger.md new file mode 100644 index 00000000..052dd0c6 --- /dev/null +++ b/docs/25-automation-execution-ledger.md @@ -0,0 +1,226 @@ +# Automation execution ledger and Card output + +Status: Loop 3 implementation contract + +Parent: `docs/22-jtype-agent-work-prd.md` + +## Outcome + +An Automation is no longer only configuration plus `last_error`. Every trigger +decision becomes an immutable execution that answers: + +- what triggered; +- whether Cloud accepted, ignored, blocked, superseded, or dispatched it; +- which Run, pull request, or jtype Card is the output; +- who can repair a blocked execution; +- whether an external writeback is complete. + +Service Kanban remains a Service capability. Its Card execution history stays +on the Card and is not duplicated in the Project Automation list. + +## User and jobs + +The primary user is a Project member operating unattended work. They need to +know whether an Automation did nothing by design, could not run, or is still in +progress without reading orchestrator logs. + +- Viewer: inspect history and follow output links. +- Member: use **Run now** with a client-generated idempotency key. +- Owner: edit trigger, output, model, and repair unhealthy dependencies. +- Cluster Admin: repair Provider/model configuration, without gaining Project + mutation rights. + +## Execution truth + +`automation_executions` is the durable control-plane occurrence. Source +receipts remain authoritative evidence: + +- SCM keeps the normalized `webhook_receipts` row; +- Cron keeps its compare-and-swap fire cursor; +- a create-card execution keeps a deterministic Card path and materialization + state; +- Kanban keeps its independent Card claim and transition occurrences. + +The public state vocabulary is: + +| State | Meaning | +| --- | --- | +| `accepted` | occurrence recorded; an output is being materialized | +| `ignored` | valid trigger intentionally did not dispatch | +| `duplicate` | a replay resolved to an existing occurrence | +| `superseded` | a newer coalesced occurrence replaced this queued work | +| `blocked` | an actionable dependency prevented output | +| `queued` | linked Run is queued | +| `running` | linked Run is active | +| `terminal` | linked Run reached succeeded, failed, or canceled | + +`running`, `terminal`, and `superseded` are projections of the linked Run when +possible, including a `create_card` Run reached through the Card claim and when +the API applies a state filter. A canceled Run whose +phase is `Superseded` is not shown as a generic failure. + +Webhook delivery/payload replays resolve through the source receipt and return +the original occurrence; they do not create a second ledger row or rewrite the +original row to `duplicate`. Distinct SCM occurrences may share a Run +`coalesce_key`; an older queued Run then projects as `superseded`. A receipt +whose ledger write failed is reclaimable so a Provider retry can finish the +same delivery instead of being mistaken for completed work. + +Every reason is sanitized and typed (`reason_code`, `message`, `repair_role`). +No raw webhook body, prompt/response payload, header, token, or encrypted +credential enters the ledger. + +## Outputs + +| Trigger / run kind | Output | +| --- | --- | +| SCM `review` | Run plus provider-native review / pull request | +| SCM `agent` | direct Run | +| Cron `run_only` | direct Run | +| Cron `create_card` | deterministic jtype Card, then normal Service Kanban | +| Manual Run now | the Automation's configured output | + +`create_card` requires an enabled, healthy Service Kanban binding. It writes a +Card directly into that binding's trigger column. The Cron dispatcher never +creates a Run for the same execution. + +## Idempotency and crash recovery + +### Manual + +The client sends a UUID-like idempotency key. `(automation_id, event_key)` is +unique and retained permanently, exceeding the required 24-hour retry window. +Concurrent submissions return the same execution and Run. + +### Cron + +The event key uses the scheduled fire boundary rather than poll time: +`cron::`. Claiming the Cron cursor, inserting +the execution, and optionally inserting the direct Run are one Store +transaction. + +### create_card + +The Card path is derived from the execution id: + +`jcode-automation//.md` + +The body contains a stable, non-secret execution marker. Materialization is a +small state machine: + +1. `planned` is persisted with the execution. +2. Cloud atomically claims it as `creating` before the external save. +3. If the path already exists and carries the same marker, Cloud binds it. +4. If the path exists with another marker, the execution is blocked + `card_path_conflict`. +5. After `SaveDocument`, Cloud resolves the same path and binds its document id. + A lost save response or a temporarily invisible/unreadable path remains + `creating`; it is visible as blocked but stays eligible for recovery. +6. A restarted `creating` execution resolves the path; it never blindly saves + again. If the path is absent, the result is `card_creation_uncertain`, not a + duplicate Card. +7. Once bound, normal jtype events create the Service Kanban occurrence and + Run. The Automation history projects that linked Run through the Card claim. +8. A later missing Card is `card_unavailable`; it is never recreated. + +The Automation ledger keeps polling blocked `create_card` executions because +materialization dependencies may recover. Blocked direct-Run decisions are +terminal ledger facts and do not poll indefinitely. + +The board proxy treats this namespace as managed: a Member may edit or move an +existing, same-board Automation Card, but cannot create a new document under +`jcode-automation/`. Only the materializer creates those paths. + +This deliberately prefers a visible ambiguous failure over fabricating +exactly-once success across Cloud and jtype. + +## API + +- `GET /api/v1/automations/{id}/executions` + - Viewer + - newest first, cursor pagination + - optional `state` filter +- `GET /api/v1/automations/{id}/executions/{execution_id}` + - Viewer +- `POST /api/v1/automations/{id}/executions` + - Member + - `{ "idempotency_key": "..." }` + - `201` for a new execution, `200` for an idempotent replay +- Cron configuration adds `output_mode: run_only | create_card`. + +Each execution view contains trigger, state/outcome, sanitized reason, actor +snapshot, Run/Card/PR output refs, writeback state, and timestamps. Usage is +explicitly `unavailable` until Loop 4 supplies a summary; it is never rendered +as zero. + +## UI + +`design/automation-executions.html` is the page-scoped reference. + +The Automation detail page puts operational truth before configuration: + +1. title, enabled state, trigger summary, **Run now**, Edit; +2. filterable execution ledger; +3. a selected execution inspector with Trigger, Output, Accountability, + Writeback, and Usage; +4. concise repair action for blocked rows; +5. configuration summary last. + +On narrow screens, the ledger becomes stacked cards and the inspector follows +the selected card. State is never conveyed by color alone. + +The editor exposes Cron output as a two-option choice. `create_card` is disabled +when Service Kanban policy is missing or blocked, with the policy blocker and +repair role shown inline. + +Card output links use the canonical Project workspace route and hand the exact +document path to `jtype-board-react`. A Viewer can resolve the linked board and +read the Card through the proxy, but the embed is read-only and every proxy +write still requires Member. The Card detail polls while it has no occurrence +so an accepted or blocked receipt appears after the Card enters the trigger +column; polling stops once all known occurrences are terminal. + +## Test design (written before implementation) + +### Store contract + +1. concurrent Manual submissions create one execution and one Run; +2. event-key replay returns the existing execution; +3. Cron cursor + execution + direct Run commit atomically; +4. blocked Cron commits an execution without a Run; +5. newest-first cursor pagination is stable for equal timestamps; +6. state filtering cannot leak another Automation's execution; +7. automation deletion cannot rewrite historical snapshots; +8. PostgreSQL round-trips nullable Card/Run/reason/actor fields. + +### Dispatcher + +1. SCM match records queued output; filter/ignore records ignored; every + post-match dependency/authorization exit records blocked, and a failed + ledger write leaves a reclaimable error receipt; +2. model/provider/service failures record blocked with repair role; +3. Cron `run_only` produces exactly one Run; +4. Cron `create_card` produces no direct Run; +5. healthy create-card saves deterministic path in trigger column; +6. crash, lost save response, or post-save read failure recovers by binding the + existing marker/path without a second save; +7. `creating` with missing path blocks uncertain and never calls save; +8. path collision, JType unavailable, deleted Card, and disabled Kanban are + visible blocked/unavailable states. + +### API and UI + +1. Viewer can list but cannot Run now; +2. Member double-submit gets `201`, then `200`, with the same ids; +3. output links resolve to Run/Card/PR without returning secrets; +4. legacy/empty history says “No executions yet”, not “0 runs”; +5. blocked, ignored, superseded, running, and terminal rows are distinct; +6. mobile preserves state, reason, time, and primary output; +7. create-card option is disabled with a visible repair action when Kanban is + unhealthy; +8. the output URL preserves an encoded Card path and uses a valid workspace + tab; +9. Viewer can read the linked Board/Card but cannot save, while Member may + update an existing managed Automation Card and cannot forge a new one; +10. an empty Card detail continues polling until its first execution receipt + appears. diff --git a/orchestrator/cmd/orchestrator/main.go b/orchestrator/cmd/orchestrator/main.go index 388852a9..f3c277b4 100644 --- a/orchestrator/cmd/orchestrator/main.go +++ b/orchestrator/cmd/orchestrator/main.go @@ -16,6 +16,7 @@ import ( "time" "github.com/cnjack/jcloud/internal/api" + "github.com/cnjack/jcloud/internal/automationcard" "github.com/cnjack/jcloud/internal/config" "github.com/cnjack/jcloud/internal/jtype" "github.com/cnjack/jcloud/internal/k8s" @@ -207,7 +208,8 @@ func run(log *slog.Logger) error { // (fail-visible: a blocked window records last_error, never a silent skip). if cfg.SchedulePollInterval > 0 { sp := schedule.NewPoller(st, srv.Models(), - schedule.NewHostGate(st, cfg.AllowedGitHosts), log, cfg.SchedulePollInterval) + schedule.NewHostGate(st, cfg.AllowedGitHosts), log, cfg.SchedulePollInterval). + WithCardMaterializer(automationcard.New(st, decrypt)) go sp.Run(ctx) log.Info("schedule poller enabled", "interval", cfg.SchedulePollInterval) } else { diff --git a/orchestrator/internal/api/api.go b/orchestrator/internal/api/api.go index 2bfb54d7..d52ea777 100644 --- a/orchestrator/internal/api/api.go +++ b/orchestrator/internal/api/api.go @@ -485,6 +485,9 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/automations/{aid}", s.authed(s.handleGetPluginAutomation)) 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/automations/{aid}/executions", s.authed(s.handleListAutomationExecutions)) + mux.Handle("POST /api/v1/automations/{aid}/executions", s.authed(s.handleRunAutomationNow)) + mux.Handle("GET /api/v1/automations/{aid}/executions/{eid}", s.authed(s.handleGetAutomationExecution)) 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)) diff --git a/orchestrator/internal/api/automation_executions.go b/orchestrator/internal/api/automation_executions.go new file mode 100644 index 00000000..44490140 --- /dev/null +++ b/orchestrator/internal/api/automation_executions.go @@ -0,0 +1,474 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/cnjack/jcloud/internal/domain" + "github.com/cnjack/jcloud/internal/modelcfg" + "github.com/cnjack/jcloud/internal/provenance" + "github.com/cnjack/jcloud/internal/store" +) + +type automationExecutionCursor struct { + CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` +} + +type automationOutputView struct { + Kind string `json:"kind"` + Label string `json:"label"` + Href string `json:"href,omitempty"` + Available bool `json:"available"` +} + +type automationRunView struct { + ID string `json:"id"` + Status domain.RunStatus `json:"status"` + Href string `json:"href"` +} + +type automationCardView struct { + WorkspaceID string `json:"workspace_id"` + DocumentID string `json:"document_id,omitempty"` + DocumentPath string `json:"document_path"` + Href string `json:"href,omitempty"` + Available bool `json:"available"` +} + +type automationExecutionView struct { + ID string `json:"id"` + AutomationID string `json:"automation_id"` + AutomationName string `json:"automation_name"` + TriggerKind string `json:"trigger_kind"` + State domain.AutomationExecutionState `json:"state"` + Outcome string `json:"outcome,omitempty"` + OutputMode string `json:"output_mode"` + ReasonCode string `json:"reason_code,omitempty"` + Reason string `json:"reason,omitempty"` + RepairRole string `json:"repair_role,omitempty"` + RequestedActor *domain.ProvenanceActorRef `json:"requested_actor"` + AccountableActor *domain.ProvenanceActorRef `json:"accountable_actor"` + Output automationOutputView `json:"output"` + Run *automationRunView `json:"run"` + Card *automationCardView `json:"card"` + ExternalURL string `json:"external_url,omitempty"` + WritebackState string `json:"writeback_state"` + Usage map[string]string `json:"usage"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TerminalAt *time.Time `json:"terminal_at,omitempty"` +} + +func (s *Server) handleListAutomationExecutions(w http.ResponseWriter, r *http.Request) { + spec, _, ok := s.loadPluginAutomationForMember(w, r, domain.RoleViewer) + if !ok { + return + } + if spec.Automation.TriggerKind == "kanban" { + writeError(w, http.StatusNotFound, "not_found", "Automation not found") + return + } + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state != "" && !domain.ValidAutomationExecutionState(domain.AutomationExecutionState(state)) { + writeError(w, http.StatusBadRequest, "bad_request", "state is not a valid Automation execution state") + return + } + limit := 20 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value < 1 || value > 50 { + writeError(w, http.StatusBadRequest, "bad_request", "limit must be between 1 and 50") + return + } + limit = value + } + before, err := decodeAutomationExecutionCursor(strings.TrimSpace(r.URL.Query().Get("before"))) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_cursor", "before is not a valid Automation execution cursor") + return + } + var beforeAt *time.Time + beforeID := "" + if before != nil { + beforeAt, beforeID = &before.CreatedAt, before.ID + } + values, err := s.st.ListAutomationExecutions(r.Context(), spec.Automation.ID, state, beforeAt, beforeID, limit+1) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not list Automation executions") + return + } + var nextCursor *string + if len(values) > limit { + values = values[:limit] + cursor := encodeAutomationExecutionCursor(values[len(values)-1]) + nextCursor = &cursor + } + items := make([]automationExecutionView, 0, len(values)) + for i := range values { + view, err := s.automationExecutionView(r, &values[i]) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not project Automation execution") + return + } + items = append(items, view) + } + writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nextCursor}) +} + +func (s *Server) handleGetAutomationExecution(w http.ResponseWriter, r *http.Request) { + spec, _, ok := s.loadPluginAutomationForMember(w, r, domain.RoleViewer) + if !ok { + return + } + if spec.Automation.TriggerKind == "kanban" { + writeError(w, http.StatusNotFound, "not_found", "Automation not found") + return + } + value, err := s.st.GetAutomationExecution(r.Context(), spec.Automation.ID, r.PathValue("eid")) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Automation execution not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not load Automation execution") + return + } + view, err := s.automationExecutionView(r, value) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not project Automation execution") + return + } + writeJSON(w, http.StatusOK, view) +} + +type runAutomationNowRequest struct { + IdempotencyKey string `json:"idempotency_key"` +} + +func (s *Server) handleRunAutomationNow(w http.ResponseWriter, r *http.Request) { + spec, svc, ok := s.loadPluginAutomationForMember(w, r, domain.RoleMember) + if !ok { + return + } + if spec.Automation.TriggerKind == "kanban" { + writeError(w, http.StatusNotFound, "not_found", "Automation not found") + return + } + var req runAutomationNowRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON: "+err.Error()) + return + } + key := strings.TrimSpace(req.IdempotencyKey) + if len(key) < 8 || len(key) > 128 || strings.ContainsAny(key, " \t\r\n/\\") { + writeError(w, http.StatusBadRequest, "bad_request", "idempotency_key must be 8-128 non-space characters") + return + } + eventKey := "manual:" + key + if existing, err := s.st.GetAutomationExecutionByEventKey(r.Context(), spec.Automation.ID, eventKey); err == nil { + view, projectErr := s.automationExecutionView(r, existing) + if projectErr != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not project Automation execution") + return + } + writeJSON(w, http.StatusOK, view) + return + } else if !errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusInternalServerError, "internal", "could not check Automation execution idempotency") + return + } + + now := time.Now().UTC() + value := &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: spec.Automation.ID, + AutomationName: spec.Automation.Name, PromptSnapshot: spec.Automation.PromptTemplate, + ProjectID: svc.ProjectID, ServiceID: svc.ID, TriggerKind: "manual", + EventKey: eventKey, State: domain.AutomationExecutionAccepted, + OutputMode: domain.AutomationOutputRunOnly, + RequestedActor: manualExecutionActor(principalFrom(r.Context())), + AccountableActor: s.automationOwnerActor(r, spec.Automation.CreatedBy), + CreatedAt: now, UpdatedAt: now, + } + if spec.Cron != nil && spec.Cron.OutputMode != "" { + value.OutputMode = spec.Cron.OutputMode + } + if value.OutputMode == domain.AutomationOutputCreateCard { + value.CardPath = automationExecutionCardPath(spec.Automation.ID, value.ID) + value.CardState = "planned" + value.WritebackState = "pending" + s.createManualAutomationExecution(w, r, value, nil) + return + } + if spec.Automation.RunKind == domain.RunKindReview { + value.State = domain.AutomationExecutionBlocked + value.ReasonCode = "manual_review_requires_pull_request" + value.ReasonMessage = "Run now cannot create a native review without a pull request." + value.RepairRole = "project_owner" + s.createManualAutomationExecution(w, r, value, nil) + return + } + allowed, host, err := s.integrationHostStillAllowed(r.Context(), svc) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not check the integration host policy") + return + } + if !allowed { + value.State = domain.AutomationExecutionBlocked + value.ReasonCode = "host_not_allowed" + value.ReasonMessage = "The Service repository host is no longer allowed: " + host + value.RepairRole = "cluster_admin" + s.createManualAutomationExecution(w, r, value, nil) + return + } + sel, outcome, err := s.models.SelectModel(r.Context(), svc.ProjectID, deref(svc.DefaultModelID), spec.Automation.ModelID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not resolve Automation model") + return + } + if outcome != modelcfg.SelectOK || !sel.SupportsEffort(spec.Automation.ModelEffort) { + value.State = domain.AutomationExecutionBlocked + value.ReasonCode = automationModelReasonCode(outcome, sel.SupportsEffort(spec.Automation.ModelEffort)) + if outcome == modelcfg.SelectOK { + value.ReasonMessage = "The selected model no longer supports this Automation's reasoning effort." + } else { + value.ReasonMessage = pluginAutomationModelError(outcome, nil) + } + value.RepairRole = "project_owner" + if outcome == modelcfg.SelectNotConfigured { + value.RepairRole = "cluster_admin" + } + s.createManualAutomationExecution(w, r, value, nil) + return + } + run := newQueuedRun(svc.ProjectID, svc.ID, spec.Automation.PromptTemplate, nil, principalFrom(r.Context()).userIDPtr()) + run.Origin = domain.RunOriginAPI + run.OriginAutomationID = spec.Automation.ID + run.OriginEventKey = eventKey + run.ModelName = sel.ModelName + run.ModelEffort = spec.Automation.ModelEffort + if sel.ModelID != "" { + modelID := sel.ModelID + run.ModelID = &modelID + } + provenance.Stamp(r.Context(), s.st, run, nil) + value.State = domain.AutomationExecutionQueued + value.RunID = run.ID + s.createManualAutomationExecution(w, r, value, run) +} + +func (s *Server) createManualAutomationExecution(w http.ResponseWriter, r *http.Request, value *domain.AutomationExecution, run *domain.Run) { + saved, created, err := s.st.CreateAutomationExecution(r.Context(), value, run) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not create Automation execution") + return + } + if run != nil && created { + s.emitStatus(r.Context(), run) + } + view, err := s.automationExecutionView(r, saved) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "could not project Automation execution") + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + writeJSON(w, status, view) +} + +func (s *Server) automationExecutionView(r *http.Request, value *domain.AutomationExecution) (automationExecutionView, error) { + view := automationExecutionView{ + ID: value.ID, AutomationID: value.AutomationID, AutomationName: value.AutomationName, + TriggerKind: value.TriggerKind, State: value.State, Outcome: value.Outcome, + OutputMode: value.OutputMode, ReasonCode: value.ReasonCode, Reason: value.ReasonMessage, + RepairRole: value.RepairRole, ExternalURL: value.ExternalURL, + WritebackState: value.WritebackState, CreatedAt: value.CreatedAt, + UpdatedAt: value.UpdatedAt, TerminalAt: value.TerminalAt, + Usage: map[string]string{"state": "unavailable"}, + } + if value.RequestedActor.Kind != "" { + actor := value.RequestedActor + view.RequestedActor = &actor + } + if value.AccountableActor.Kind != "" { + actor := value.AccountableActor + view.AccountableActor = &actor + } + var run *domain.Run + if value.RunID != "" { + loaded, err := s.st.GetRun(r.Context(), value.RunID) + if err != nil && !errors.Is(err, store.ErrNotFound) { + return view, err + } + run = loaded + } + cardAvailable := value.CardState == "bound" + if value.CardPath != "" { + view.Card = &automationCardView{ + WorkspaceID: value.CardWorkspaceID, DocumentID: value.CardDocumentID, + DocumentPath: value.CardPath, Available: cardAvailable, + Href: "/projects/" + value.ProjectID + "?service=" + value.ServiceID + + "&tab=automations&kanban=1&card=" + url.QueryEscape(value.CardPath), + } + if value.CardAutomationID != "" && value.CardWorkspaceID != "" { + claim, err := s.st.GetPluginKanbanClaimByPath( + r.Context(), value.CardAutomationID, value.CardWorkspaceID, value.CardPath, + ) + if err == nil { + view.Card.Available = claim.ExternalRefAvailable + if !claim.ExternalRefAvailable { + view.ReasonCode = "card_unavailable" + view.Reason = "The jtype Card is no longer available." + view.RepairRole = "project_owner" + if run == nil { + view.State = domain.AutomationExecutionBlocked + } + } + if claim.LatestOccurrenceID != "" { + occurrences, listErr := s.st.ListPluginKanbanOccurrences(r.Context(), value.CardAutomationID, claim.DocumentID, 1) + if listErr != nil { + return view, listErr + } + if len(occurrences) > 0 { + occurrence := occurrences[0] + view.WritebackState = occurrence.WritebackState + if run == nil && occurrence.RunID != "" { + loaded, loadErr := s.st.GetRun(r.Context(), occurrence.RunID) + if loadErr != nil && !errors.Is(loadErr, store.ErrNotFound) { + return view, loadErr + } + run = loaded + } + } + } + } else if !errors.Is(err, store.ErrNotFound) { + return view, err + } + } + view.Output = automationOutputView{ + Kind: "card", Label: value.CardPath, Href: view.Card.Href, Available: view.Card.Available, + } + } + if run != nil { + projectAutomationRunState(&view, run) + view.Run = &automationRunView{ID: run.ID, Status: run.Status, Href: "/runs/" + run.ID} + view.Output = automationOutputView{ + Kind: "run", Label: "Run " + run.ID, Href: "/runs/" + run.ID, Available: true, + } + if value.OutputMode == domain.AutomationOutputCreateCard && view.Card != nil { + view.Output = automationOutputView{ + Kind: "card", Label: value.CardPath, Href: view.Card.Href, Available: view.Card.Available, + } + } + } else if value.RunID != "" { + view.Output = automationOutputView{Kind: "run", Label: "Run unavailable", Available: false} + } + if view.Output.Kind == "" { + view.Output = automationOutputView{Kind: "none", Label: "No output", Available: false} + } + return view, nil +} + +func projectAutomationRunState(view *automationExecutionView, run *domain.Run) { + switch { + case run.Status == domain.StatusCanceled && run.Phase == "Superseded": + view.State = domain.AutomationExecutionSuperseded + view.Outcome = "canceled" + view.TerminalAt = run.FinishedAt + case run.Status.Terminal(): + view.State = domain.AutomationExecutionTerminal + view.Outcome = string(run.Status) + view.TerminalAt = run.FinishedAt + case run.Status == domain.StatusRunning || run.Status == domain.StatusAwaitingInput || run.Status == domain.StatusScheduling: + view.State = domain.AutomationExecutionRunning + default: + view.State = domain.AutomationExecutionQueued + } + view.UpdatedAt = run.CreatedAt + if run.StartedAt != nil { + view.UpdatedAt = *run.StartedAt + } + if run.FinishedAt != nil { + view.UpdatedAt = *run.FinishedAt + } +} + +func encodeAutomationExecutionCursor(value domain.AutomationExecution) string { + payload, _ := json.Marshal(automationExecutionCursor{CreatedAt: value.CreatedAt.UTC(), ID: value.ID}) + return base64.RawURLEncoding.EncodeToString(payload) +} + +func decodeAutomationExecutionCursor(raw string) (*automationExecutionCursor, error) { + if raw == "" { + return nil, nil + } + payload, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, err + } + var value automationExecutionCursor + if err := json.Unmarshal(payload, &value); err != nil { + return nil, err + } + if value.CreatedAt.IsZero() || value.ID == "" { + return nil, errors.New("cursor fields are required") + } + return &value, nil +} + +func manualExecutionActor(principal *principal) domain.ProvenanceActorRef { + if principal != nil && principal.user != nil { + label := principal.user.DisplayName + if label == "" { + label = principal.user.ID + } + return domain.ProvenanceActorRef{Kind: "cloud_user", ID: principal.user.ID, Label: label} + } + if principal != nil && principal.isAPIKey() { + return domain.ProvenanceActorRef{Kind: "service_principal", Label: "Project API key"} + } + return domain.ProvenanceActorRef{Kind: "service_principal", Label: "Cloud service principal"} +} + +func (s *Server) automationOwnerActor(r *http.Request, userID string) domain.ProvenanceActorRef { + if userID == "" { + return domain.ProvenanceActorRef{Kind: "automation", Label: "Automation rule"} + } + user, err := s.st.GetUser(r.Context(), userID) + if err != nil { + return domain.ProvenanceActorRef{Kind: "cloud_user", ID: userID, Label: "Former member"} + } + label := user.DisplayName + if label == "" { + label = user.ID + } + return domain.ProvenanceActorRef{Kind: "cloud_user", ID: user.ID, Label: label} +} + +func automationExecutionCardPath(automationID, executionID string) string { + return "jcode-automation/" + automationID + "/" + executionID + ".md" +} + +func automationModelReasonCode(outcome modelcfg.SelectOutcome, effortSupported bool) string { + if !effortSupported { + return "model_effort_unsupported" + } + switch outcome { + case modelcfg.SelectNotConfigured: + return "model_not_configured" + case modelcfg.SelectNotSelected: + return "model_not_selected" + case modelcfg.SelectNotGranted: + return "model_not_granted" + default: + return "model_unavailable" + } +} diff --git a/orchestrator/internal/api/automation_executions_test.go b/orchestrator/internal/api/automation_executions_test.go new file mode 100644 index 00000000..e34ed422 --- /dev/null +++ b/orchestrator/internal/api/automation_executions_test.go @@ -0,0 +1,202 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + "time" + + "github.com/cnjack/jcloud/internal/domain" +) + +func TestAutomationExecutionRBACAndManualIdempotency(t *testing.T) { + f := setupRBAC(t) + now := time.Now().UTC() + automation := &domain.PluginAutomation{ + ID: domain.NewID(), ServiceID: f.serviceID, Name: "Run now", + TriggerKind: "cron", PromptTemplate: "inspect the repository", + Enabled: true, CreatedAt: now, + } + if err := f.st.CreatePluginAutomation( + t.Context(), automation, nil, nil, nil, + &domain.CronTrigger{ + AutomationID: automation.ID, CronExpr: "0 9 * * *", + OutputMode: domain.AutomationOutputRunOnly, + }, + ); err != nil { + t.Fatal(err) + } + listURL := f.ts.URL + "/api/v1/automations/" + automation.ID + "/executions" + resp := do(t, http.MethodGet, listURL, f.tokens["viewer"], nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("viewer list=%d want 200", resp.StatusCode) + } + resp.Body.Close() + + resp = do(t, http.MethodPost, listURL, f.tokens["viewer"], map[string]any{"idempotency_key": "same-browser-key"}) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("viewer run now=%d want 403", resp.StatusCode) + } + resp.Body.Close() + + resp = do(t, http.MethodPost, listURL, f.tokens["member"], map[string]any{"idempotency_key": "same-browser-key"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("first run now=%d want 201", resp.StatusCode) + } + var first automationExecutionView + decode(t, resp, &first) + if first.State != domain.AutomationExecutionQueued || first.Run == nil || + first.RequestedActor == nil || first.RequestedActor.Label != "member" { + t.Fatalf("first=%+v", first) + } + + resp = do(t, http.MethodPost, listURL, f.tokens["member"], map[string]any{"idempotency_key": "same-browser-key"}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("replay=%d want 200", resp.StatusCode) + } + var replay automationExecutionView + decode(t, resp, &replay) + if replay.ID != first.ID || replay.Run == nil || replay.Run.ID != first.Run.ID { + t.Fatalf("first=%+v replay=%+v", first, replay) + } + runs, err := f.st.ListRunsByService(t.Context(), f.serviceID, -1) + if err != nil || len(runs) != 1 { + t.Fatalf("runs=%+v err=%v", runs, err) + } + + resp = do(t, http.MethodGet, listURL, f.tokens["viewer"], nil) + var raw map[string]any + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + t.Fatal(err) + } + resp.Body.Close() + body, _ := json.Marshal(raw) + if string(body) == "" || containsAny(string(body), "event_key", "prompt_snapshot", "inspect the repository") { + t.Fatalf("public ledger leaked internal fields: %s", body) + } +} + +func containsAny(value string, candidates ...string) bool { + for _, candidate := range candidates { + if len(candidate) > 0 && len(value) >= len(candidate) { + for i := 0; i+len(candidate) <= len(value); i++ { + if value[i:i+len(candidate)] == candidate { + return true + } + } + } + } + return false +} + +func TestAutomationExecutionRejectsInvalidCursorAndState(t *testing.T) { + f := setupRBAC(t) + automation := &domain.PluginAutomation{ + ID: domain.NewID(), ServiceID: f.serviceID, Name: "History", + TriggerKind: "cron", PromptTemplate: "work", Enabled: true, CreatedAt: time.Now().UTC(), + } + if err := f.st.CreatePluginAutomation(t.Context(), automation, nil, nil, nil, &domain.CronTrigger{ + AutomationID: automation.ID, CronExpr: "0 9 * * *", OutputMode: domain.AutomationOutputRunOnly, + }); err != nil { + t.Fatal(err) + } + base := f.ts.URL + "/api/v1/automations/" + automation.ID + "/executions" + for _, suffix := range []string{"?before=bad", "?state=succeeded"} { + resp := do(t, http.MethodGet, base+suffix, f.tokens["viewer"], nil) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("%s status=%d want 400", suffix, resp.StatusCode) + } + resp.Body.Close() + } +} + +func TestKanbanAutomationExecutionRoutesFailVisibly(t *testing.T) { + f := setupRBAC(t) + now := time.Now().UTC() + installation := &domain.PluginInstallation{ + ID: domain.NewID(), ProjectID: f.projectID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, ConsentVersion: "v1", + ConsentedAt: now, CreatedAt: now, + } + if err := f.st.CreatePluginInstallation(t.Context(), installation); err != nil { + t.Fatal(err) + } + automation := &domain.PluginAutomation{ + ID: domain.NewID(), ServiceID: f.serviceID, InstallationID: installation.ID, + Name: "Service Kanban", TriggerKind: "kanban", PromptTemplate: "{{card}}", + Enabled: true, CreatedAt: now, + } + if err := f.st.CreatePluginAutomation(t.Context(), automation, nil, nil, &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "board", TriggerColumn: "agent", + }, nil); err != nil { + t.Fatal(err) + } + base := f.ts.URL + "/api/v1/automations/" + automation.ID + "/executions" + for _, method := range []string{http.MethodGet, http.MethodPost} { + var body any + if method == http.MethodPost { + body = map[string]any{"idempotency_key": "kanban-route-key"} + } + resp := do(t, method, base, f.tokens["member"], body) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("%s status=%d want 404", method, resp.StatusCode) + } + resp.Body.Close() + } + resp := do(t, http.MethodGet, base+"/missing", f.tokens["viewer"], nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("detail status=%d want 404", resp.StatusCode) + } + resp.Body.Close() +} + +func TestAutomationExecutionCardDeepLinkUsesWorkspaceContract(t *testing.T) { + f := setupRBAC(t) + now := time.Now().UTC() + automation := &domain.PluginAutomation{ + ID: domain.NewID(), ServiceID: f.serviceID, Name: "Create triage Card", + TriggerKind: "cron", PromptTemplate: "triage", Enabled: true, CreatedAt: now, + } + if err := f.st.CreatePluginAutomation(t.Context(), automation, nil, nil, nil, &domain.CronTrigger{ + AutomationID: automation.ID, CronExpr: "0 9 * * *", + OutputMode: domain.AutomationOutputCreateCard, + }); err != nil { + t.Fatal(err) + } + execution := &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: automation.ID, AutomationName: automation.Name, + ProjectID: f.projectID, ServiceID: f.serviceID, TriggerKind: "cron", + EventKey: "cron:deep-link", State: domain.AutomationExecutionAccepted, + OutputMode: domain.AutomationOutputCreateCard, + CardPath: "jcode-automation/" + automation.ID + "/payment + review.md", + CardState: "bound", CreatedAt: now, UpdatedAt: now, + } + if _, created, err := f.st.CreateAutomationExecution(t.Context(), execution, nil); err != nil || !created { + t.Fatalf("create execution created=%v err=%v", created, err) + } + + resp := do(t, http.MethodGet, + f.ts.URL+"/api/v1/automations/"+automation.ID+"/executions/"+execution.ID, + f.tokens["viewer"], nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("execution detail status=%d", resp.StatusCode) + } + var view automationExecutionView + decode(t, resp, &view) + if view.Card == nil || !view.Card.Available { + t.Fatalf("card=%+v", view.Card) + } + href, err := url.Parse(view.Card.Href) + if err != nil { + t.Fatal(err) + } + if href.Path != "/projects/"+f.projectID || + href.Query().Get("service") != f.serviceID || + href.Query().Get("tab") != "automations" || + href.Query().Get("kanban") != "1" || + href.Query().Get("card") != execution.CardPath { + t.Fatalf("Card href=%q query=%v", view.Card.Href, href.Query()) + } +} diff --git a/orchestrator/internal/api/kanban_board.go b/orchestrator/internal/api/kanban_board.go index ec117c20..7db093a3 100644 --- a/orchestrator/internal/api/kanban_board.go +++ b/orchestrator/internal/api/kanban_board.go @@ -52,8 +52,8 @@ type boardEmbedLinkView struct { Enabled bool `json:"enabled"` } -// jtypeBoardProxy is the slice of *jtype.Client the member+ board embed proxy uses -// to forward document reads/writes to jtype verbatim (D31). A test injects a fake +// jtypeBoardProxy is the slice of *jtype.Client the board embed proxy uses to +// forward document reads/writes to jtype verbatim (D31). A test injects a fake // so the proxy endpoints are exercised without HTTP. type jtypeBoardProxy interface { ProxyDocumentAPI(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) @@ -65,12 +65,12 @@ type jtypeBoardProxy interface { const maxProxyBody = 8 << 20 // handleListBoardEmbedLinks returns the project's kanban links in the reduced, -// credential-free boardEmbedLinkView (member+). The console gates the Kanban -// button on a non-empty list and drives the modal's link picker from it. Viewers -// (and non-members) get a 403 → the button never renders. +// credential-free boardEmbedLinkView (viewer+). The console gates the manual +// Kanban button to member+, while execution-output deep links use this read-only +// discovery path for viewers. Non-members still receive 403. func (s *Server) handleListBoardEmbedLinks(w http.ResponseWriter, r *http.Request) { projectID := r.PathValue("id") - if !s.authorizeProject(r.Context(), w, principalFrom(r.Context()), projectID, domain.RoleMember) { + if !s.authorizeProject(r.Context(), w, principalFrom(r.Context()), projectID, domain.RoleViewer) { return } automations, err := s.st.ListPluginAutomationsByProject(r.Context(), projectID) @@ -101,13 +101,18 @@ func (s *Server) handleListBoardEmbedLinks(w http.ResponseWriter, r *http.Reques } // resolveBoardProxy is the shared gate for every documents/* handler. It -// authorizes member+, enforces the confused-deputy workspace-scoping guard, and -// resolves the effective jtype client + token server-side — or writes a typed -// fail-visible error and returns ok=false (the caller must stop). The returned -// client already carries the resolved token; the workspace string is validated. +// authorizes viewer+ reads and member+ writes, enforces the confused-deputy +// workspace-scoping guard, and resolves the effective jtype client + token +// server-side — or writes a typed fail-visible error and returns ok=false (the +// caller must stop). The returned client already carries the resolved token; +// the workspace string is validated. func (s *Server) resolveBoardProxy(w http.ResponseWriter, r *http.Request) (client jtypeBoardProxy, workspace, credential string, ok bool) { projectID := r.PathValue("id") - if !s.authorizeProject(r.Context(), w, principalFrom(r.Context()), projectID, domain.RoleMember) { + requiredRole := domain.RoleViewer + if r.Method != http.MethodGet { + requiredRole = domain.RoleMember + } + if !s.authorizeProject(r.Context(), w, principalFrom(r.Context()), projectID, requiredRole) { return nil, "", "", false } @@ -258,11 +263,11 @@ func (s *Server) handleBoardGetDocument(w http.ResponseWriter, r *http.Request) // handleBoardSaveDocument proxies board-react's saveDocument(ws, req) — a card // create/edit/move (member+). The (bounded) body is buffered so the target path -// can be validated: the member+ embed may only write CARD documents (`.md`), -// never the `.board` config JSON or a traversal/other-type path — so a member -// can't overwrite an arbitrary note in the linked workspace via a crafted save -// (the board itself only ever saves card `.md` files). jtype's SaveDocument -// response (mergeStatus/contentHash) is copied back verbatim. +// can be validated: the embed may create ordinary Cards under `cards/` and may +// update an existing Cloud-managed Automation Card under `jcode-automation/`. +// It can never create in the managed namespace, write a `.board` config JSON, +// or target a traversal/other-type path. jtype's SaveDocument response +// (mergeStatus/contentHash) is copied back verbatim. func (s *Server) handleBoardSaveDocument(w http.ResponseWriter, r *http.Request) { client, ws, credential, ok := s.resolveBoardProxy(w, r) if !ok { @@ -288,11 +293,13 @@ func (s *Server) handleBoardSaveDocument(w http.ResponseWriter, r *http.Request) } normalized := strings.ReplaceAll(rp, "\\", "/") cleaned := path.Clean(normalized) + ordinaryCard := strings.HasPrefix(cleaned, "cards/") + managedAutomationCard := strings.HasPrefix(cleaned, "jcode-automation/") if cleaned != normalized || strings.HasPrefix(cleaned, "/") || - !strings.HasPrefix(cleaned, "cards/") || + (!ordinaryCard && !managedAutomationCard) || !strings.HasSuffix(strings.ToLower(cleaned), ".md") { writeError(w, http.StatusForbidden, "forbidden", - "the board embed may only write card documents under cards/") + "the board embed may only write card documents under cards/ or existing Cloud Automation Cards") return } card := jtype.ParseCard(req.Content) @@ -307,6 +314,11 @@ func (s *Server) handleBoardSaveDocument(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadGateway, "jtype_invalid_response", "could not validate the existing JType card") return } + if managedAutomationCard && !exists { + writeError(w, http.StatusForbidden, "forbidden", + "Cloud Automation Cards must already exist before the board can update them") + return + } if exists && existingBoard != card.Board { writeError(w, http.StatusForbidden, "forbidden", "the existing card belongs to a different board") diff --git a/orchestrator/internal/api/plugin_automations.go b/orchestrator/internal/api/plugin_automations.go index 7376eae8..83ae0046 100644 --- a/orchestrator/internal/api/plugin_automations.go +++ b/orchestrator/internal/api/plugin_automations.go @@ -97,7 +97,8 @@ type pluginKanbanReq struct { DoneColumn string `json:"done_column"` } type pluginCronReq struct { - CronExpr string `json:"cron_expr"` + CronExpr string `json:"cron_expr"` + OutputMode string `json:"output_mode"` } func (s *Server) handleListPluginAutomations(w http.ResponseWriter, r *http.Request) { @@ -157,7 +158,7 @@ func (s *Server) handleCreatePluginAutomation(w http.ResponseWriter, r *http.Req writeError(w, modelErr.status, modelErr.code, modelErr.msg) return } - installationID, msg := s.validatePluginAutomationTarget(r, svc, scm, actions, kanban) + installationID, msg := s.validatePluginAutomationTarget(r, svc, scm, actions, kanban, cron) if msg != "" { writeError(w, 409, "plugin_unavailable", msg) return @@ -286,7 +287,7 @@ func (s *Server) handleUpdatePluginAutomation(w http.ResponseWriter, r *http.Req writeError(w, modelErr.status, modelErr.code, modelErr.msg) return } - installationID, msg := s.validatePluginAutomationTarget(r, svc, scm, actions, kanban) + installationID, msg := s.validatePluginAutomationTarget(r, svc, scm, actions, kanban, cron) if msg != "" { writeError(w, 409, "plugin_unavailable", msg) return @@ -508,7 +509,14 @@ func pluginAutomationFromReq(req pluginAutomationReq, id string) (*domain.Plugin if _, err := schedule.ParseCron(expr); err != nil { return nil, nil, nil, nil, nil, "cron.cron_expr is invalid: " + err.Error() } - return a, nil, nil, nil, &domain.CronTrigger{CronExpr: expr}, "" + outputMode := strings.TrimSpace(req.Cron.OutputMode) + if outputMode == "" { + outputMode = domain.AutomationOutputRunOnly + } + if !domain.ValidAutomationOutputMode(outputMode) { + return nil, nil, nil, nil, nil, "cron.output_mode must be run_only or create_card" + } + return a, nil, nil, nil, &domain.CronTrigger{CronExpr: expr, OutputMode: outputMode}, "" } func (s *Server) validatePluginAutomationModel(r *http.Request, svc *domain.Service, a *domain.PluginAutomation, required bool) *automationHTTPError { @@ -548,7 +556,7 @@ func (s *Server) validatePluginAutomationModel(r *http.Request, svc *domain.Serv return nil } -func (s *Server) validatePluginAutomationTarget(r *http.Request, svc *domain.Service, scm *domain.SCMTrigger, actions []domain.SCMAction, kanban *domain.KanbanTrigger) (string, string) { +func (s *Server) validatePluginAutomationTarget(r *http.Request, svc *domain.Service, scm *domain.SCMTrigger, actions []domain.SCMAction, kanban *domain.KanbanTrigger, cron *domain.CronTrigger) (string, string) { if scm != nil { b, err := s.st.GetServiceRepositoryBinding(r.Context(), svc.ID) if err != nil { @@ -579,6 +587,34 @@ func (s *Server) validatePluginAutomationTarget(r *http.Request, svc *domain.Ser } return in.ID, "" } + if cron != nil && cron.OutputMode == domain.AutomationOutputCreateCard { + automations, err := s.st.ListPluginAutomationsByProject(r.Context(), svc.ProjectID) + if err != nil { + return "", "Service Kanban policy could not be read" + } + for i := range automations { + candidate := automations[i] + if candidate.ServiceID != svc.ID || candidate.TriggerKind != "kanban" || !candidate.Enabled { + continue + } + spec, loadErr := s.st.GetPluginAutomationSpec(r.Context(), candidate.ID) + if loadErr != nil || spec.Kanban == nil { + continue + } + installation, installErr := s.st.GetPluginInstallation(r.Context(), spec.Kanban.InstallationID) + if installErr != nil || installation.Status != domain.PluginStatusEnabled || + installation.LastHealthError != "" || installation.Provider != domain.PluginJType { + return "", "the Service Kanban JType Plugin needs attention before Card output can be enabled" + } + cfg, cfgErr := s.st.GetProviderConfig(r.Context(), domain.PluginJType) + if cfgErr != nil || !cfg.PluginEnabled || cfg.LastHealthError != "" || + cfg.ConfigRevision != installation.ConfigRevision { + return "", "the cluster JType Provider needs attention before Card output can be enabled" + } + return "", "" + } + return "", "enable a healthy Service Kanban policy before selecting Card output" + } return "", "" } func specToSCM(s *domain.PluginAutomationSpec) *pluginSCMReq { @@ -601,5 +637,5 @@ func specToCron(s *domain.PluginAutomationSpec) *pluginCronReq { if s.Cron == nil { return nil } - return &pluginCronReq{CronExpr: s.Cron.CronExpr} + return &pluginCronReq{CronExpr: s.Cron.CronExpr, OutputMode: s.Cron.OutputMode} } diff --git a/orchestrator/internal/api/plugin_webhook.go b/orchestrator/internal/api/plugin_webhook.go index 02b6dbee..634f7514 100644 --- a/orchestrator/internal/api/plugin_webhook.go +++ b/orchestrator/internal/api/plugin_webhook.go @@ -200,21 +200,73 @@ func (s *Server) handlePluginWebhook(w http.ResponseWriter, r *http.Request) { return } dispatched := 0 + failReceipt := func(message string) { + s.completePluginReceipt(r, receipt, "error", receipt.MatchedAutomationID, message) + writeError(w, http.StatusInternalServerError, "execution_ledger_failed", message) + } + recordDecision := func( + automation *domain.PluginAutomation, + service *domain.Service, + state domain.AutomationExecutionState, + reasonCode, reasonMessage, repairRole string, + ) bool { + if err := s.recordPluginSCMDecision( + r, automation, service, event, state, reasonCode, reasonMessage, repairRole, + ); err != nil { + failReceipt("could not record Automation execution") + return false + } + return true + } for i := range automations { a := &automations[i] if routeBinding != nil && a.ServiceID != routeBinding.ServiceID { continue } + svc, svcErr := s.st.GetService(r.Context(), a.ServiceID) + if svcErr != nil { + failReceipt("could not load Automation Service") + return + } spec, specErr := s.st.GetPluginAutomationSpec(r.Context(), a.ID) if specErr != nil || spec.SCM == nil { s.recordPluginAutomationError(r, a, "Automation trigger configuration is unavailable.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "trigger_configuration_unavailable", "Automation trigger configuration is unavailable.", "project_owner") { + return + } + continue + } + if svc.DeletingAt != nil { + s.recordPluginAutomationError(r, a, "Automation Service is unavailable.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "service_unavailable", "Automation Service is being deleted.", "project_owner") { + return + } + continue + } + binding, bindErr := s.st.GetServiceRepositoryBinding(r.Context(), svc.ID) + if bindErr != nil { + s.recordPluginAutomationError(r, a, "Automation Service repository binding is unavailable.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "repository_unavailable", "Automation Service repository binding is unavailable.", "project_owner") { + return + } continue } if a.RunKind == domain.RunKindReview && event.Family == scmevent.FamilyPullRequest && event.Draft && !spec.SCM.IncludeDrafts { + if !recordDecision(a, svc, domain.AutomationExecutionIgnored, + "draft_pull_request", "Draft pull requests are excluded by this Automation.", "") { + return + } continue } if a.IgnoreJCode && event.GeneratedByJCode { + if !recordDecision(a, svc, domain.AutomationExecutionIgnored, + "jcode_generated", "The Automation ignores events generated by jcode.", "") { + return + } continue } filter := scmevent.Filter{ @@ -225,16 +277,28 @@ func (s *Server) handlePluginWebhook(w http.ResponseWriter, r *http.Request) { filter.IncludePaths = splitFilterValues(spec.SCM.PathPattern) } if !filter.Matches(event, event.ChangedPaths) { + if !recordDecision(a, svc, domain.AutomationExecutionIgnored, + "filter_mismatch", "The event did not match this Automation's filters.", "") { + return + } continue } - svc, svcErr := s.st.GetService(r.Context(), a.ServiceID) - if svcErr != nil || svc.DeletingAt != nil { - s.recordPluginAutomationError(r, a, "Automation Service is unavailable.") + allowed, host, hostErr := s.integrationHostStillAllowed(r.Context(), svc) + if hostErr != nil { + s.recordPluginAutomationError(r, a, "The Service repository host policy could not be checked.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "host_policy_unavailable", "The Service repository host policy could not be checked.", "cluster_admin") { + return + } continue } - binding, bindErr := s.st.GetServiceRepositoryBinding(r.Context(), svc.ID) - if bindErr != nil { - s.recordPluginAutomationError(r, a, "Automation Service repository binding is unavailable.") + if !allowed { + reason := "The Service repository host is no longer allowed: " + host + s.recordPluginAutomationError(r, a, reason) + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "host_not_allowed", reason, "cluster_admin") { + return + } continue } if receipt.InstallationID == "" { @@ -258,17 +322,29 @@ func (s *Server) handlePluginWebhook(w http.ResponseWriter, r *http.Request) { } if bindErr != nil || manualClient == nil || manualOwner == "" || manualRepo == "" { s.recordPluginAutomationError(r, a, "GitHub App credential is unavailable for review acknowledgement.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "provider_credential_unavailable", "The repository Provider credential is unavailable.", "project_owner") { + return + } continue } identity, identityErr := s.st.GetIdentity(r.Context(), domain.GitProvider(provider), event.Actor.ID) if identityErr != nil { manualReply("jcode couldn't find a linked Cloud account for this GitHub user. Sign in to jcode Cloud with GitHub first.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "cloud_account_unlinked", "The requester must link their repository account to jcode Cloud.", "requester") { + return + } continue } user, userErr := s.st.GetUser(r.Context(), identity.UserID) member, memberErr := s.st.GetMember(r.Context(), svc.ProjectID, identity.UserID) if userErr != nil || (!user.IsClusterAdmin && (memberErr != nil || !member.Role.AtLeast(domain.RoleMember))) { manualReply("jcode can't start a review for this account. Ask a project owner to add you as a member.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "requester_not_authorized", "The requester is not authorized to run this Project Automation.", "project_owner") { + return + } continue } userID := identity.UserID @@ -276,6 +352,10 @@ func (s *Server) handlePluginWebhook(w http.ResponseWriter, r *http.Request) { pr, prErr := manualClient.PRByNumber(r.Context(), manualOwner, manualRepo, int(event.Object.Number)) if prErr != nil || pr == nil || pr.HeadRef == "" || pr.BaseRef == "" { manualReply("jcode couldn't read this pull request. Check that the App still has Pull requests: read and write permission.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "pull_request_unavailable", "The repository Provider could not read this pull request.", "project_owner") { + return + } continue } event.Ref, event.BaseRef = pr.HeadRef, pr.BaseRef @@ -284,6 +364,19 @@ func (s *Server) handlePluginWebhook(w http.ResponseWriter, r *http.Request) { sel, outcome, selectErr := s.models.SelectModel(r.Context(), svc.ProjectID, deref(svc.DefaultModelID), a.ModelID) if selectErr != nil || outcome != modelcfg.SelectOK { s.recordPluginAutomationError(r, a, pluginAutomationModelError(outcome, selectErr)) + repairRole := "project_owner" + reasonCode := automationModelReasonCode(outcome, true) + reasonMessage := pluginAutomationModelError(outcome, selectErr) + if selectErr != nil { + repairRole = "cluster_admin" + reasonCode = "model_resolution_failed" + } else if outcome == modelcfg.SelectNotConfigured { + repairRole = "cluster_admin" + } + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + reasonCode, reasonMessage, repairRole) { + return + } if manualReview { manualReply("jcode couldn't start this review because no usable model is configured. Ask a project owner to check the review settings.") } @@ -291,6 +384,10 @@ func (s *Server) handlePluginWebhook(w http.ResponseWriter, r *http.Request) { } if !sel.SupportsEffort(a.ModelEffort) { s.recordPluginAutomationError(r, a, "The selected Automation model no longer supports reasoning effort.") + if !recordDecision(a, svc, domain.AutomationExecutionBlocked, + "model_effort_unsupported", "The selected Automation model no longer supports reasoning effort.", "project_owner") { + return + } if manualReview { manualReply("jcode couldn't start this review because the selected model no longer supports its configured reasoning effort.") } @@ -354,20 +451,27 @@ func (s *Server) handlePluginWebhook(w http.ResponseWriter, r *http.Request) { Provider: string(provider), ID: event.Actor.ID, Label: event.Actor.Login, Source: "scm_event", }) - var createErr error if coalesceKey := pluginRunCoalesceKey(a.ID, svc.ID, event); coalesceKey != "" && !manualReview { - createErr = s.st.CreateCoalescedRun(r.Context(), coalesceKey, run) - } else { - createErr = s.st.CreateRun(r.Context(), run) + run.CoalesceKey = coalesceKey } + execution := s.newPluginSCMExecution(r, a, svc, event, domain.AutomationExecutionQueued) + execution.RunID = run.ID + _, created, createErr := s.st.CreateAutomationExecution(r.Context(), execution, run) if createErr != nil { - if _, duplicateErr := s.st.GetRunByOriginEventKey(r.Context(), run.OriginEventKey); duplicateErr == nil { - continue + if existingRun, duplicateErr := s.st.GetRunByOriginEventKey(r.Context(), run.OriginEventKey); duplicateErr == nil { + execution.RunID = existingRun.ID + if _, _, recoverErr := s.st.CreateAutomationExecution(r.Context(), execution, nil); recoverErr == nil { + continue + } } s.recordPluginAutomationError(r, a, "Automation Run could not be created.") if manualReview { manualReply("jcode couldn't queue this review. Try again; if it continues, ask a project owner to check the Automation status.") } + failReceipt("could not create Automation execution") + return + } + if !created { continue } nowTriggered := time.Now().UTC() @@ -537,6 +641,63 @@ func (s *Server) recordPluginAutomationError(r *http.Request, a *domain.PluginAu _ = s.st.UpdatePluginAutomation(r.Context(), a) } +func (s *Server) newPluginSCMExecution( + r *http.Request, + automation *domain.PluginAutomation, + service *domain.Service, + event scmevent.NormalizedSCMEvent, + state domain.AutomationExecutionState, +) *domain.AutomationExecution { + now := time.Now().UTC() + return &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: automation.ID, AutomationName: automation.Name, + PromptSnapshot: automation.PromptTemplate, ProjectID: service.ProjectID, ServiceID: service.ID, + TriggerKind: "scm", EventKey: pluginEventKey(automation, service.ID, event), + State: state, OutputMode: domain.AutomationOutputRunOnly, + RequestedActor: s.pluginSCMRequestedActor(r, event), + AccountableActor: s.automationOwnerActor(r, automation.CreatedBy), + ExternalURL: event.Object.URL, CreatedAt: now, UpdatedAt: now, + } +} + +func (s *Server) pluginSCMRequestedActor(r *http.Request, event scmevent.NormalizedSCMEvent) domain.ProvenanceActorRef { + external := domain.ProvenanceActorRef{ + Kind: "external_actor", ID: event.Actor.ID, Label: event.Actor.Login, + Provider: string(event.Provider), + } + identity, err := s.st.GetIdentity(r.Context(), domain.GitProvider(event.Provider), event.Actor.ID) + if err != nil { + return external + } + actor := domain.ProvenanceActorRef{ + Kind: "cloud_user", ID: identity.UserID, Label: "Former member", + Provider: string(event.Provider), ExternalID: event.Actor.ID, ExternalLabel: event.Actor.Login, + } + if user, userErr := s.st.GetUser(r.Context(), identity.UserID); userErr == nil { + actor.Label = strings.TrimSpace(user.DisplayName) + if actor.Label == "" { + actor.Label = identity.UserID + } + } + return actor +} + +func (s *Server) recordPluginSCMDecision( + r *http.Request, + automation *domain.PluginAutomation, + service *domain.Service, + event scmevent.NormalizedSCMEvent, + state domain.AutomationExecutionState, + reasonCode, reasonMessage, repairRole string, +) error { + execution := s.newPluginSCMExecution(r, automation, service, event, state) + execution.ReasonCode = reasonCode + execution.ReasonMessage = reasonMessage + execution.RepairRole = repairRole + _, _, err := s.st.CreateAutomationExecution(r.Context(), execution, nil) + return err +} + func (s *Server) completePluginReceipt(r *http.Request, receipt *domain.WebhookReceipt, status, automationID, message string) { receipt.Status = status receipt.MatchedAutomationID = automationID diff --git a/orchestrator/internal/api/plugin_webhook_test.go b/orchestrator/internal/api/plugin_webhook_test.go index 302e18b0..960eec59 100644 --- a/orchestrator/internal/api/plugin_webhook_test.go +++ b/orchestrator/internal/api/plugin_webhook_test.go @@ -463,6 +463,17 @@ func TestPluginReviewMentionIsAuthorizedAndRepeatable(t *testing.T) { t.Fatalf("manual review run=%+v", run) } } + executions, err := f.st.ListAutomationExecutions(ctx, automationID, "", nil, "", 10) + if err != nil || len(executions) != 2 { + t.Fatalf("executions=%+v err=%v", executions, err) + } + for _, execution := range executions { + if execution.RequestedActor.Kind != "cloud_user" || + execution.RequestedActor.ID != user.ID || + execution.RequestedActor.ExternalID != "9001" { + t.Fatalf("requested actor=%+v", execution.RequestedActor) + } + } } func TestPluginGitLabWebhookUsesPerBindingTokenAndDispatches(t *testing.T) { diff --git a/orchestrator/internal/api/service_kanban_test.go b/orchestrator/internal/api/service_kanban_test.go index dc0891a1..a4fc4758 100644 --- a/orchestrator/internal/api/service_kanban_test.go +++ b/orchestrator/internal/api/service_kanban_test.go @@ -332,6 +332,18 @@ func TestServiceKanbanUsesDefaultTriggerAndStaysOutOfAutomations(t *testing.T) { if err := st.CreateService(ctx, service); err != nil { t.Fatal(err) } + member := mkUser(t, st, "service-kanban-member") + viewer := mkUser(t, st, "service-kanban-viewer") + for _, membership := range []*domain.ProjectMember{ + {ProjectID: project.ID, UserID: member.ID, Role: domain.RoleMember, CreatedAt: now}, + {ProjectID: project.ID, UserID: viewer.ID, Role: domain.RoleViewer, CreatedAt: now}, + } { + if err := st.UpsertMember(ctx, membership); err != nil { + t.Fatal(err) + } + } + memberToken := mkSession(t, st, member.ID) + viewerToken := mkSession(t, st, viewer.ID) if err := st.UpsertProviderConfig(ctx, &domain.ProviderConfig{Provider: domain.PluginJType, BaseURL: "https://jtype.test", PluginEnabled: true, ConfigRevision: 1}); err != nil { t.Fatal(err) } @@ -485,6 +497,35 @@ func TestServiceKanbanUsesDefaultTriggerAndStaysOutOfAutomations(t *testing.T) { t.Fatalf("board links=%+v", links.Links) } + // Viewer execution-output links can discover and read the linked board, but + // writes remain member+ and must stop before any upstream request. + resp = do(t, http.MethodGet, ts.URL+"/api/v1/projects/"+project.ID+"/kanban/board/links", viewerToken, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("viewer board links status=%d", resp.StatusCode) + } + var viewerLinks struct { + Links []boardEmbedLinkView `json:"links"` + } + decode(t, resp, &viewerLinks) + if len(viewerLinks.Links) != 1 || viewerLinks.Links[0].BoardRef != "b_board" { + t.Fatalf("viewer board links=%+v", viewerLinks.Links) + } + before := proxy.calls + resp = do(t, http.MethodGet, ts.URL+"/api/v1/projects/"+project.ID+"/kanban/board/documents?workspace=workspace-1", viewerToken, nil) + if resp.StatusCode != http.StatusOK || proxy.calls != before+1 { + t.Fatalf("viewer board read status=%d upstream_calls=%d want=%d", resp.StatusCode, proxy.calls, before+1) + } + resp.Body.Close() + before = proxy.calls + resp = do(t, http.MethodPost, ts.URL+"/api/v1/projects/"+project.ID+"/kanban/board/documents/save?workspace=workspace-1", viewerToken, map[string]any{ + "relativePath": "cards/viewer.md", + "content": "---\nboard: b_board\nstatus: ai\n---\nread only", + }) + if resp.StatusCode != http.StatusForbidden || proxy.calls != before { + t.Fatalf("viewer board write status=%d upstream_calls=%d want=%d", resp.StatusCode, proxy.calls, before) + } + resp.Body.Close() + // A compromised upstream cannot reflect the server-held token to a member. proxy.body = `{"echo":"jtype-secret-that-must-not-leak"}` resp = do(t, http.MethodGet, ts.URL+"/api/v1/projects/"+project.ID+"/kanban/board/documents?workspace=workspace-1", consoleToken, nil) @@ -503,7 +544,7 @@ func TestServiceKanbanUsesDefaultTriggerAndStaysOutOfAutomations(t *testing.T) { // Even with a valid card body, the board proxy cannot overwrite an arbitrary // Markdown note outside the card namespace. proxy.body = `{}` - before := proxy.calls + before = proxy.calls resp = do(t, http.MethodPost, ts.URL+"/api/v1/projects/"+project.ID+"/kanban/board/documents/save?workspace=workspace-1", consoleToken, map[string]any{ "relativePath": "notes/private.md", "content": "---\nboard: b_board\nstatus: ai\n---\nsecret", @@ -530,6 +571,38 @@ func TestServiceKanbanUsesDefaultTriggerAndStaysOutOfAutomations(t *testing.T) { } resp.Body.Close() + // Cloud Automation Cards use a separate deterministic namespace. Members + // may update one only after the proxy proves that exact Card already exists. + managedPath := "jcode-automation/automation-1/execution-1.md" + proxy.bodies = []string{ + `[{"id":"managed-card","relativePath":"` + managedPath + `"}]`, + `{"content":"---\nboard: b_board\nstatus: ai\n---\nmanaged","contentHash":"managed-hash"}`, + `{"relativePath":"` + managedPath + `","contentHash":"next-hash","mergeStatus":"accepted"}`, + } + before = proxy.calls + resp = do(t, http.MethodPost, ts.URL+"/api/v1/projects/"+project.ID+"/kanban/board/documents/save?workspace=workspace-1", memberToken, map[string]any{ + "relativePath": managedPath, + "content": "---\nboard: b_board\nstatus: done\n---\nmanaged", + "baseContentHash": "managed-hash", + }) + if resp.StatusCode != http.StatusOK || proxy.calls != before+3 { + t.Fatalf("managed Card update status=%d upstream_calls=%d want=%d", resp.StatusCode, proxy.calls, before+3) + } + resp.Body.Close() + + // A crafted request cannot create a new document in Cloud's managed + // namespace; only the materializer owns that operation. + proxy.bodies = []string{`[]`} + before = proxy.calls + resp = do(t, http.MethodPost, ts.URL+"/api/v1/projects/"+project.ID+"/kanban/board/documents/save?workspace=workspace-1", memberToken, map[string]any{ + "relativePath": "jcode-automation/automation-1/forged.md", + "content": "---\nboard: b_board\nstatus: ai\n---\nforged", + }) + if resp.StatusCode != http.StatusForbidden || proxy.calls != before+1 { + t.Fatalf("managed Card create status=%d upstream_calls=%d want=%d", resp.StatusCode, proxy.calls, before+1) + } + resp.Body.Close() + resp = do(t, http.MethodDelete, ts.URL+"/api/v1/services/"+service.ID+"/kanban", consoleToken, nil) if resp.StatusCode != http.StatusNoContent { t.Fatalf("disable status=%d", resp.StatusCode) diff --git a/orchestrator/internal/automationcard/materializer.go b/orchestrator/internal/automationcard/materializer.go new file mode 100644 index 00000000..82c9e880 --- /dev/null +++ b/orchestrator/internal/automationcard/materializer.go @@ -0,0 +1,183 @@ +package automationcard + +import ( + "context" + "fmt" + "path" + "strconv" + "strings" + "time" + + "github.com/cnjack/jcloud/internal/domain" + "github.com/cnjack/jcloud/internal/jtype" + "github.com/cnjack/jcloud/internal/store" +) + +type documentAPI interface { + ListDocuments(ctx context.Context, workspace string) ([]jtype.Doc, error) + GetDocument(ctx context.Context, workspace, id string) (*jtype.Document, error) + SaveDocument(ctx context.Context, workspace, path, content, baseContentHash string) error +} + +type Materializer struct { + st store.Store + decrypt func([]byte) (string, error) + clientFor func(baseURL, token string) documentAPI +} + +func New(st store.Store, decrypt func([]byte) (string, error)) *Materializer { + return &Materializer{ + st: st, decrypt: decrypt, + clientFor: func(baseURL, token string) documentAPI { + return jtype.NewClient(baseURL, token, 20*time.Second) + }, + } +} + +// Materialize creates or recovers one deterministic Card. allowSave is true +// only for the process that changed planned -> creating. A restarted creating +// execution may resolve an existing Card but can never issue another save. +func (m *Materializer) Materialize(ctx context.Context, execution domain.AutomationExecution, allowSave bool) domain.AutomationCardMaterializationResult { + result := domain.AutomationCardMaterializationResult{DocumentPath: execution.CardPath, CardState: "creating"} + spec, installation, client, blocked := m.resolveTarget(ctx, execution) + if blocked.ReasonCode != "" { + blocked.DocumentPath = execution.CardPath + return blocked + } + result.CardAutomationID = spec.Automation.ID + result.WorkspaceID = installation.WorkspaceID + + docs, err := client.ListDocuments(ctx, installation.WorkspaceID) + if err != nil { + if allowSave { + result.CardState = "planned" + } + return retryableResult(result, "card_read_unavailable", "JType cards could not be read.", "project_owner") + } + if doc := findPath(docs, execution.CardPath); doc != nil { + return m.bindExisting(ctx, client, execution, result, *doc) + } + if !allowSave { + return blockedResult(result, "card_creation_uncertain", "Cloud cannot prove whether JType created this Card. It will not create another one.", "project_owner") + } + + content := cardContent(spec.Kanban.BoardRef, spec.Kanban.TriggerColumn, execution) + if err := client.SaveDocument(ctx, installation.WorkspaceID, execution.CardPath, content, ""); err != nil { + return retryableResult(result, "card_write_unavailable", "JType did not confirm Card creation. Cloud will only try to resolve the same path.", "project_owner") + } + docs, err = client.ListDocuments(ctx, installation.WorkspaceID) + if err != nil { + return retryableResult(result, "card_creation_unconfirmed", "JType accepted the save, but Cloud could not resolve the Card yet.", "project_owner") + } + doc := findPath(docs, execution.CardPath) + if doc == nil { + return retryableResult(result, "card_creation_unconfirmed", "JType accepted the save, but the Card path is not visible yet.", "project_owner") + } + return m.bindExisting(ctx, client, execution, result, *doc) +} + +func (m *Materializer) resolveTarget( + ctx context.Context, + execution domain.AutomationExecution, +) (*domain.PluginAutomationSpec, *domain.PluginInstallation, documentAPI, domain.AutomationCardMaterializationResult) { + automations, err := m.st.ListPluginAutomationsByProject(ctx, execution.ProjectID) + if err != nil { + return nil, nil, nil, blockedResult(domain.AutomationCardMaterializationResult{}, "kanban_policy_unavailable", "Service Kanban policy could not be read.", "project_owner") + } + var spec *domain.PluginAutomationSpec + for i := range automations { + candidate := automations[i] + if candidate.ServiceID != execution.ServiceID || candidate.TriggerKind != "kanban" || !candidate.Enabled { + continue + } + loaded, loadErr := m.st.GetPluginAutomationSpec(ctx, candidate.ID) + if loadErr == nil && loaded.Kanban != nil { + spec = loaded + break + } + } + if spec == nil { + return nil, nil, nil, blockedResult(domain.AutomationCardMaterializationResult{}, "kanban_not_configured", "Enable Service Kanban before using Card output.", "project_owner") + } + installation, err := m.st.GetPluginInstallation(ctx, spec.Kanban.InstallationID) + if err != nil || installation.Status != domain.PluginStatusEnabled || + installation.Provider != domain.PluginJType || installation.WorkspaceID == "" || + installation.LastHealthError != "" { + return nil, nil, nil, blockedResult(domain.AutomationCardMaterializationResult{}, "jtype_unavailable", "Reconnect the Project JType plugin.", "project_owner") + } + cfg, err := m.st.GetProviderConfig(ctx, domain.PluginJType) + if err != nil || !cfg.PluginEnabled || cfg.BaseURL == "" || cfg.LastHealthError != "" || + cfg.ConfigRevision != installation.ConfigRevision { + return nil, nil, nil, blockedResult(domain.AutomationCardMaterializationResult{}, "jtype_provider_unavailable", "The cluster JType Provider needs attention.", "cluster_admin") + } + token, _, err := jtype.ResolveToken(installation.AccessTokenEnc, m.decrypt) + if err != nil { + return nil, nil, nil, blockedResult(domain.AutomationCardMaterializationResult{}, "jtype_credential_unavailable", "Reconnect the Project JType plugin.", "project_owner") + } + return spec, installation, m.clientFor(cfg.BaseURL, token), domain.AutomationCardMaterializationResult{} +} + +func (m *Materializer) bindExisting( + ctx context.Context, + client documentAPI, + execution domain.AutomationExecution, + result domain.AutomationCardMaterializationResult, + doc jtype.Doc, +) domain.AutomationCardMaterializationResult { + full, err := client.GetDocument(ctx, result.WorkspaceID, doc.ID) + if err != nil { + return retryableResult(result, "card_read_unavailable", "The deterministic Card path exists but cannot be read yet.", "project_owner") + } + if !strings.Contains(full.Content, executionMarker(execution.ID)) { + return blockedResult(result, "card_path_conflict", "The deterministic Card path is occupied by a different document.", "project_owner") + } + result.DocumentID = doc.ID + result.DocumentPath = doc.Path + result.CardState = "bound" + return result +} + +func blockedResult(result domain.AutomationCardMaterializationResult, code, message, role string) domain.AutomationCardMaterializationResult { + result.CardState = "unavailable" + result.ReasonCode = code + result.ReasonMessage = message + result.RepairRole = role + return result +} + +func retryableResult(result domain.AutomationCardMaterializationResult, code, message, role string) domain.AutomationCardMaterializationResult { + if result.CardState == "" { + result.CardState = "creating" + } + result.ReasonCode = code + result.ReasonMessage = message + result.RepairRole = role + return result +} + +func findPath(docs []jtype.Doc, wanted string) *jtype.Doc { + for i := range docs { + if docs[i].Path == wanted { + return &docs[i] + } + } + return nil +} + +func executionMarker(id string) string { + return "" +} + +func cardContent(board, status string, execution domain.AutomationExecution) string { + title := execution.AutomationName + if title == "" { + title = "Automation work" + } + return fmt.Sprintf("---\nboard: %s\nstatus: %s\ntitle: %s\n---\n%s\n\n%s\n", + strconv.Quote(board), strconv.Quote(status), strconv.Quote(title), + executionMarker(execution.ID), strings.TrimSpace(execution.PromptSnapshot)) +} + +func DeterministicPath(automationID, executionID string) string { + return path.Join("jcode-automation", automationID, executionID+".md") +} diff --git a/orchestrator/internal/automationcard/materializer_test.go b/orchestrator/internal/automationcard/materializer_test.go new file mode 100644 index 00000000..49713c2a --- /dev/null +++ b/orchestrator/internal/automationcard/materializer_test.go @@ -0,0 +1,185 @@ +package automationcard + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/cnjack/jcloud/internal/domain" + "github.com/cnjack/jcloud/internal/jtype" + "github.com/cnjack/jcloud/internal/store" +) + +type fakeDocuments struct { + docs []jtype.Doc + content map[string]string + saveCalls int + listCalls int + listError map[int]error + saveError error +} + +func (f *fakeDocuments) ListDocuments(context.Context, string) ([]jtype.Doc, error) { + f.listCalls++ + if err := f.listError[f.listCalls]; err != nil { + return nil, err + } + return append([]jtype.Doc(nil), f.docs...), nil +} +func (f *fakeDocuments) GetDocument(_ context.Context, _ string, id string) (*jtype.Document, error) { + body, ok := f.content[id] + if !ok { + return nil, errors.New("missing") + } + for _, doc := range f.docs { + if doc.ID == id { + return &jtype.Document{Path: doc.Path, Content: body}, nil + } + } + return nil, errors.New("missing") +} +func (f *fakeDocuments) SaveDocument(_ context.Context, _ string, path, content, _ string) error { + f.saveCalls++ + id := "doc-created" + f.docs = append(f.docs, jtype.Doc{ID: id, Path: path, Title: "Card"}) + f.content[id] = content + return f.saveError +} + +func materializerFixture(t *testing.T) (*Materializer, domain.AutomationExecution, *fakeDocuments) { + t.Helper() + ctx := context.Background() + st := store.NewMemStore() + now := time.Now().UTC() + project := &domain.Project{ID: "project", Name: "P", CreatedAt: now} + service := &domain.Service{ + ID: "service", ProjectID: project.ID, Name: "S", + RepoKind: domain.RepoKindRaw, RawRepoURL: "u", DefaultBranch: "main", CreatedAt: now, + } + 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: "https://jtype.example", + PluginEnabled: true, ConfigRevision: 1, UpdatedAt: now, + } + if err := st.UpsertProviderConfig(ctx, cfg); err != nil { + t.Fatal(err) + } + installation := &domain.PluginInstallation{ + ID: "installation", ProjectID: project.ID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "workspace", + AccessTokenEnc: []byte("token"), ConfigRevision: cfg.ConfigRevision, + ConsentVersion: "v1", ConsentedAt: now, CreatedAt: now, UpdatedAt: now, + } + if err := st.CreatePluginInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + automation := &domain.PluginAutomation{ + ID: "kanban", ServiceID: service.ID, InstallationID: installation.ID, + Name: "Service Kanban", TriggerKind: "kanban", PromptTemplate: "{{card}}", + Enabled: true, CreatedAt: now, + } + trigger := &domain.KanbanTrigger{ + AutomationID: automation.ID, InstallationID: installation.ID, + BoardRef: "board-id", TriggerColumn: "agent", + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + api := &fakeDocuments{content: map[string]string{}, listError: map[int]error{}} + materializer := New(st, func(value []byte) (string, error) { return string(value), nil }) + materializer.clientFor = func(_, _ string) documentAPI { return api } + execution := domain.AutomationExecution{ + ID: "execution", AutomationID: "cron", AutomationName: "Issue sweep", + PromptSnapshot: "Triage open issues.", ProjectID: project.ID, ServiceID: service.ID, + OutputMode: domain.AutomationOutputCreateCard, + CardPath: DeterministicPath("cron", "execution"), + } + return materializer, execution, api +} + +func TestMaterializeCreatesDeterministicCardInTriggerColumn(t *testing.T) { + materializer, execution, api := materializerFixture(t) + result := materializer.Materialize(context.Background(), execution, true) + if result.CardState != "bound" || result.DocumentID != "doc-created" || api.saveCalls != 1 { + t.Fatalf("result=%+v saves=%d", result, api.saveCalls) + } + body := api.content[result.DocumentID] + for _, wanted := range []string{ + `board: "board-id"`, `status: "agent"`, + executionMarker(execution.ID), execution.PromptSnapshot, + } { + if !strings.Contains(body, wanted) { + t.Fatalf("card body missing %q:\n%s", wanted, body) + } + } +} + +func TestMaterializeRecoversExistingMarkerWithoutSaving(t *testing.T) { + materializer, execution, api := materializerFixture(t) + api.docs = []jtype.Doc{{ID: "existing", Path: execution.CardPath}} + api.content["existing"] = "---\nstatus: agent\n---\n" + executionMarker(execution.ID) + result := materializer.Materialize(context.Background(), execution, false) + if result.CardState != "bound" || result.DocumentID != "existing" || api.saveCalls != 0 { + t.Fatalf("result=%+v saves=%d", result, api.saveCalls) + } +} + +func TestMaterializeRestartWithMissingPathBlocksInsteadOfRecreating(t *testing.T) { + materializer, execution, api := materializerFixture(t) + result := materializer.Materialize(context.Background(), execution, false) + if result.ReasonCode != "card_creation_uncertain" || result.CardState != "unavailable" || api.saveCalls != 0 { + t.Fatalf("result=%+v saves=%d", result, api.saveCalls) + } +} + +func TestMaterializePathCollisionIsVisible(t *testing.T) { + materializer, execution, api := materializerFixture(t) + api.docs = []jtype.Doc{{ID: "other", Path: execution.CardPath}} + api.content["other"] = "---\nstatus: todo\n---\nnot ours" + result := materializer.Materialize(context.Background(), execution, true) + if result.ReasonCode != "card_path_conflict" || api.saveCalls != 0 { + t.Fatalf("result=%+v saves=%d", result, api.saveCalls) + } +} + +func TestMaterializePostSaveAmbiguityRecoversWithoutAnotherSave(t *testing.T) { + materializer, execution, api := materializerFixture(t) + api.listError[2] = errors.New("eventual consistency") + + first := materializer.Materialize(context.Background(), execution, true) + if first.CardState != "creating" || first.ReasonCode != "card_creation_unconfirmed" || + api.saveCalls != 1 { + t.Fatalf("first=%+v saves=%d", first, api.saveCalls) + } + + recovered := materializer.Materialize(context.Background(), execution, false) + if recovered.CardState != "bound" || recovered.DocumentID != "doc-created" || + api.saveCalls != 1 { + t.Fatalf("recovered=%+v saves=%d", recovered, api.saveCalls) + } +} + +func TestMaterializeAmbiguousSaveRecoversWithoutAnotherSave(t *testing.T) { + materializer, execution, api := materializerFixture(t) + api.saveError = errors.New("response lost after save") + + first := materializer.Materialize(context.Background(), execution, true) + if first.CardState != "creating" || first.ReasonCode != "card_write_unavailable" || + api.saveCalls != 1 { + t.Fatalf("first=%+v saves=%d", first, api.saveCalls) + } + + api.saveError = nil + recovered := materializer.Materialize(context.Background(), execution, false) + if recovered.CardState != "bound" || recovered.DocumentID != "doc-created" || + api.saveCalls != 1 { + t.Fatalf("recovered=%+v saves=%d", recovered, api.saveCalls) + } +} diff --git a/orchestrator/internal/domain/plugins.go b/orchestrator/internal/domain/plugins.go index bc4e484b..7b62107f 100644 --- a/orchestrator/internal/domain/plugins.go +++ b/orchestrator/internal/domain/plugins.go @@ -205,10 +205,88 @@ type KanbanTrigger struct { type CronTrigger struct { AutomationID string `json:"automation_id"` CronExpr string `json:"cron_expr"` + OutputMode string `json:"output_mode"` // run_only | create_card LastFiredAt *time.Time `json:"last_fired_at,omitempty"` LastError string `json:"last_error,omitempty"` } +const ( + AutomationOutputRunOnly = "run_only" + AutomationOutputCreateCard = "create_card" +) + +func ValidAutomationOutputMode(value string) bool { + return value == AutomationOutputRunOnly || value == AutomationOutputCreateCard +} + +type AutomationExecutionState string + +const ( + AutomationExecutionAccepted AutomationExecutionState = "accepted" + AutomationExecutionIgnored AutomationExecutionState = "ignored" + AutomationExecutionDuplicate AutomationExecutionState = "duplicate" + AutomationExecutionSuperseded AutomationExecutionState = "superseded" + AutomationExecutionBlocked AutomationExecutionState = "blocked" + AutomationExecutionQueued AutomationExecutionState = "queued" + AutomationExecutionRunning AutomationExecutionState = "running" + AutomationExecutionTerminal AutomationExecutionState = "terminal" +) + +func ValidAutomationExecutionState(value AutomationExecutionState) bool { + switch value { + case AutomationExecutionAccepted, AutomationExecutionIgnored, + AutomationExecutionDuplicate, AutomationExecutionSuperseded, + AutomationExecutionBlocked, AutomationExecutionQueued, + AutomationExecutionRunning, AutomationExecutionTerminal: + return true + } + return false +} + +// AutomationExecution is the durable trigger decision. Display fields are +// frozen snapshots and must never be consulted for authorization. +type AutomationExecution struct { + ID string `json:"id"` + AutomationID string `json:"automation_id"` + AutomationName string `json:"automation_name"` + PromptSnapshot string `json:"-"` + ProjectID string `json:"project_id"` + ServiceID string `json:"service_id"` + TriggerKind string `json:"trigger_kind"` // scm | cron | manual + EventKey string `json:"-"` + State AutomationExecutionState `json:"state"` + Outcome string `json:"outcome,omitempty"` + OutputMode string `json:"output_mode"` + ReasonCode string `json:"reason_code,omitempty"` + ReasonMessage string `json:"reason,omitempty"` + RepairRole string `json:"repair_role,omitempty"` + RequestedActor ProvenanceActorRef `json:"requested_actor"` + AccountableActor ProvenanceActorRef `json:"accountable_actor"` + RunID string `json:"run_id,omitempty"` + ExternalURL string `json:"external_url,omitempty"` + CardAutomationID string `json:"card_automation_id,omitempty"` + CardWorkspaceID string `json:"card_workspace_id,omitempty"` + CardDocumentID string `json:"card_document_id,omitempty"` + CardPath string `json:"card_path,omitempty"` + CardState string `json:"card_state,omitempty"` // planned | creating | bound | unavailable + WritebackState string `json:"writeback_state,omitempty"` + 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"` +} + +type AutomationCardMaterializationResult struct { + CardAutomationID string + WorkspaceID string + DocumentID string + DocumentPath string + CardState string + ReasonCode string + ReasonMessage string + RepairRole string +} + // WebhookReceipt contains only the normalized and whitelisted delivery facts; // raw provider payloads and request headers are intentionally never persisted. type WebhookReceipt struct { diff --git a/orchestrator/internal/provenance/provenance.go b/orchestrator/internal/provenance/provenance.go index 40757b17..5e0e7499 100644 --- a/orchestrator/internal/provenance/provenance.go +++ b/orchestrator/internal/provenance/provenance.go @@ -75,6 +75,7 @@ func Stamp(ctx context.Context, st store.Store, run *domain.Run, external *Exter (run.Origin == domain.RunOriginAutomation || run.Origin == domain.RunOriginSchedule || run.Origin == domain.RunOriginKanban) + isManualAutomation := run.OriginAutomationID != "" && run.Origin == domain.RunOriginAPI if run.TriggeredByUserID != nil && *run.TriggeredByUserID != "" && (!isRuleExecution || hasExternalActor) { actor := cloudUserActor(ctx, st, *run.TriggeredByUserID) @@ -84,7 +85,9 @@ func Stamp(ctx context.Context, st store.Store, run *domain.Run, external *Exter actor.ExternalLabel = external.Label } snapshot.RequestedActor = &actor - snapshot.AccountableActor = cloneActor(actor) + if !isManualAutomation { + snapshot.AccountableActor = cloneActor(actor) + } snapshot.Precision = PrecisionExact } else if hasExternalActor { snapshot.RequestedActor = &domain.ProvenanceActorRef{ @@ -192,6 +195,9 @@ func attributionSource(run *domain.Run, external *ExternalActor) string { case domain.RunOriginWebhook: return "scm_comment" default: + if run.OriginAutomationID != "" { + return "manual_automation" + } return "direct_user" } } @@ -210,6 +216,9 @@ func triggerRef(run *domain.Run) TriggerRef { case domain.RunOriginWebhook: return TriggerRef{Kind: "scm_comment", Label: "PR comment", Ref: run.OriginCommentID, Href: run.OriginCommentURL} default: + if run.OriginAutomationID != "" { + return TriggerRef{Kind: "manual_automation", Label: "Automation Run now", Ref: run.OriginAutomationID} + } return TriggerRef{Kind: "api", Label: "Cloud Console / API"} } } diff --git a/orchestrator/internal/schedule/poller.go b/orchestrator/internal/schedule/poller.go index 358839b6..5e9114c0 100644 --- a/orchestrator/internal/schedule/poller.go +++ b/orchestrator/internal/schedule/poller.go @@ -28,6 +28,10 @@ type HostGate interface { IntegrationHostAllowed(ctx context.Context, svc *domain.Service) (allowed bool, host string, err error) } +type CardMaterializer interface { + Materialize(ctx context.Context, execution domain.AutomationExecution, allowSave bool) domain.AutomationCardMaterializationResult +} + // Poller scans enabled schedules each tick and dispatches an agent run for every // schedule whose next fire has come due. It owns no in-memory cursor — the // authoritative state is schedules.last_fired_at, advanced with a conditional @@ -36,11 +40,17 @@ type Poller struct { st store.Store models ModelResolver hostGate HostGate // nil => host gate skipped (no integration binding to check) + cards CardMaterializer log *slog.Logger interval time.Duration now func() time.Time } +func (p *Poller) WithCardMaterializer(value CardMaterializer) *Poller { + p.cards = value + return p +} + // NewPoller builds a Poller. models runs the D21 chain (share the API's resolver // so a model-config change is immediately visible); hostGate checks the D20 // allowlist for integration-bound services (may be nil). interval<=0 still allows @@ -78,7 +88,9 @@ func (p *Poller) Run(ctx context.Context) { // Tick performs one scan over every enabled schedule. Exported so tests (and a // manual trigger) can drive a single deterministic pass. func (p *Poller) Tick(ctx context.Context) { + p.materializeAutomationCards(ctx) p.tickPluginAutomations(ctx) + p.materializeAutomationCards(ctx) // The legacy schedule scan is retained only for in-process migration-era // callers. Migration 0043 clears the table and the public CRUD routes are // gone, so production dispatch is exclusively the unified Automation path. @@ -119,18 +131,56 @@ func (p *Poller) firePluginAutomation(ctx context.Context, spec *domain.PluginAu base = *spec.Cron.LastFiredAt } now := p.now().UTC() - if parsed.Next(base.UTC()).After(now) { + scheduledAt := parsed.Next(base.UTC()) + if scheduledAt.After(now) { return } svc, err := p.st.GetService(ctx, spec.Automation.ServiceID) if err != nil { return } + execution := &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: spec.Automation.ID, + AutomationName: spec.Automation.Name, PromptSnapshot: spec.Automation.PromptTemplate, + ProjectID: svc.ProjectID, ServiceID: svc.ID, TriggerKind: "cron", + EventKey: "cron:" + spec.Automation.ID + ":" + scheduledAt.UTC().Format(time.RFC3339Nano), + State: domain.AutomationExecutionAccepted, OutputMode: spec.Cron.OutputMode, + AccountableActor: automationRuleActor(ctx, p.st, spec.Automation.CreatedBy), + CreatedAt: now, UpdatedAt: now, + } + if execution.OutputMode == "" { + execution.OutputMode = domain.AutomationOutputRunOnly + } + if execution.OutputMode == domain.AutomationOutputCreateCard { + execution.CardPath = automationCardPath(spec.Automation.ID, execution.ID) + execution.CardState = "planned" + execution.WritebackState = "pending" + _, _ = p.st.ClaimPluginCronExecution(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, execution, nil) + return + } if binding, err := p.st.GetServiceRepositoryBinding(ctx, svc.ID); err == nil { installation, loadErr := p.st.GetPluginInstallation(ctx, binding.InstallationID) if loadErr != nil || installation.Status != domain.PluginStatusEnabled || installation.LastHealthError != "" { - won, _ := p.st.AdvancePluginCronAutomation(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, "Service project plugin is unavailable.") - _ = won + execution.State = domain.AutomationExecutionBlocked + execution.ReasonCode = "service_plugin_unavailable" + execution.ReasonMessage = "Service project plugin is unavailable." + execution.RepairRole = "project_owner" + _, _ = p.st.ClaimPluginCronExecution(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, execution, nil) + return + } + } + if p.hostGate != nil { + allowed, host, gateErr := p.hostGate.IntegrationHostAllowed(ctx, svc) + if gateErr != nil { + p.log.Error("cron Automation poll: host gate", "automation", spec.Automation.ID, "err", gateErr) + return + } + if !allowed { + execution.State = domain.AutomationExecutionBlocked + execution.ReasonCode = "host_not_allowed" + execution.ReasonMessage = "The Service repository host is no longer allowed: " + host + execution.RepairRole = "cluster_admin" + _, _ = p.st.ClaimPluginCronExecution(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, execution, nil) return } } @@ -139,15 +189,22 @@ func (p *Poller) firePluginAutomation(ctx context.Context, spec *domain.PluginAu return } if outcome != modelcfg.SelectOK { - _, _ = p.st.AdvancePluginCronAutomation(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, modelBlockReason(outcome)) + execution.State = domain.AutomationExecutionBlocked + execution.ReasonCode = modelBlockCode(outcome) + execution.ReasonMessage = modelBlockReason(outcome) + execution.RepairRole = "project_owner" + if outcome == modelcfg.SelectNotConfigured { + execution.RepairRole = "cluster_admin" + } + _, _ = p.st.ClaimPluginCronExecution(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, execution, nil) return } if !sel.SupportsEffort(spec.Automation.ModelEffort) { - _, _ = p.st.AdvancePluginCronAutomation(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, "selected Automation model no longer supports reasoning effort") - return - } - won, err := p.st.AdvancePluginCronAutomation(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, "") - if err != nil || !won { + execution.State = domain.AutomationExecutionBlocked + execution.ReasonCode = "model_effort_unsupported" + execution.ReasonMessage = "Selected Automation model no longer supports reasoning effort." + execution.RepairRole = "project_owner" + _, _ = p.st.ClaimPluginCronExecution(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, execution, nil) return } run := &domain.Run{ @@ -155,7 +212,7 @@ func (p *Poller) firePluginAutomation(ctx context.Context, spec *domain.PluginAu Prompt: spec.Automation.PromptTemplate, Status: domain.StatusQueued, Kind: domain.RunKindAgent, Phase: "Queued", Origin: domain.RunOriginSchedule, OriginAutomationID: spec.Automation.ID, - OriginEventKey: "cron:" + spec.Automation.ID + ":" + now.Format(time.RFC3339Nano), + OriginEventKey: execution.EventKey, Attempt: 1, CreatedAt: now, ModelName: sel.ModelName, ModelEffort: spec.Automation.ModelEffort, } @@ -164,16 +221,70 @@ func (p *Poller) firePluginAutomation(ctx context.Context, spec *domain.PluginAu run.ModelID = &modelID } provenance.Stamp(ctx, p.st, run, nil) - if err := p.st.CreateRun(ctx, run); err != nil { - automation := spec.Automation - automation.LastError = "dispatch failed" - _ = p.st.UpdatePluginAutomation(ctx, &automation) + execution.State = domain.AutomationExecutionQueued + execution.RunID = run.ID + _, _ = p.st.ClaimPluginCronExecution(ctx, spec.Automation.ID, spec.Cron.LastFiredAt, &now, execution, run) +} + +func (p *Poller) materializeAutomationCards(ctx context.Context) { + if p.cards == nil { + return + } + executions, err := p.st.ListPendingAutomationCards(ctx, 50) + if err != nil { + p.log.Error("Automation Card materializer: list", "err", err) return } - spec.Automation.LastTriggeredAt = &now - spec.Automation.LastRunID = run.ID - spec.Automation.LastError = "" - _ = p.st.UpdatePluginAutomation(ctx, &spec.Automation) + for i := range executions { + execution := executions[i] + allowSave := false + if execution.CardState == "planned" { + allowSave, err = p.st.ClaimAutomationCardCreation(ctx, execution.ID) + if err != nil || !allowSave { + continue + } + } + result := p.cards.Materialize(ctx, execution, allowSave) + if err := p.st.UpdateAutomationExecutionCard( + ctx, execution.ID, result.CardState, result.CardAutomationID, + result.WorkspaceID, result.DocumentID, result.DocumentPath, + result.ReasonCode, result.ReasonMessage, result.RepairRole, + ); err != nil { + p.log.Error("Automation Card materializer: persist", "execution", execution.ID, "err", err) + } + } +} + +func automationRuleActor(ctx context.Context, st store.Store, userID string) domain.ProvenanceActorRef { + if userID == "" { + return domain.ProvenanceActorRef{Kind: "automation", Label: "Automation rule"} + } + user, err := st.GetUser(ctx, userID) + if err != nil { + return domain.ProvenanceActorRef{Kind: "cloud_user", ID: userID, Label: "Former member"} + } + label := user.DisplayName + if label == "" { + label = user.ID + } + return domain.ProvenanceActorRef{Kind: "cloud_user", ID: userID, Label: label} +} + +func automationCardPath(automationID, executionID string) string { + return "jcode-automation/" + automationID + "/" + executionID + ".md" +} + +func modelBlockCode(outcome modelcfg.SelectOutcome) string { + switch outcome { + case modelcfg.SelectNotConfigured: + return "model_not_configured" + case modelcfg.SelectNotSelected: + return "model_not_selected" + case modelcfg.SelectNotGranted: + return "model_not_granted" + default: + return "model_unavailable" + } } // fire evaluates one schedule: if its next fire is due it CLAIMS the window diff --git a/orchestrator/internal/schedule/poller_test.go b/orchestrator/internal/schedule/poller_test.go index 3b9d5a65..397f0149 100644 --- a/orchestrator/internal/schedule/poller_test.go +++ b/orchestrator/internal/schedule/poller_test.go @@ -171,12 +171,118 @@ func TestPluginCronAutomationDispatchesExactlyOnce(t *testing.T) { if runs[0].ModelID == nil || *runs[0].ModelID != "mid" || runs[0].ModelEffort != "high" { t.Fatalf("run model=%v effort=%q want mid/high", runs[0].ModelID, runs[0].ModelEffort) } + executions, err := st.ListAutomationExecutions(ctx, automation.ID, "", nil, "", 20) + if err != nil || len(executions) != 1 || runs[0].OriginEventKey != executions[0].EventKey { + t.Fatalf("run event=%q executions=%+v err=%v", runs[0].OriginEventKey, executions, err) + } spec, err := st.GetPluginAutomationSpec(ctx, automation.ID) if err != nil || spec.Cron == nil || spec.Cron.LastFiredAt == nil || !spec.Cron.LastFiredAt.Equal(now) { t.Fatalf("spec=%+v err=%v", spec, err) } } +func TestPluginCronAutomationHostBlockCreatesLedgerOccurrence(t *testing.T) { + now := time.Date(2026, 7, 9, 15, 0, 0, 0, time.UTC) + st := store.NewMemStore() + ctx := context.Background() + project := &domain.Project{ID: domain.NewID(), Name: "p", CreatedAt: now.Add(-time.Hour)} + service := &domain.Service{ + ID: domain.NewID(), ProjectID: project.ID, Name: "svc", + RepoKind: domain.RepoKindRaw, RawRepoURL: "https://blocked.example/repo.git", + DefaultBranch: "main", CreatedAt: now.Add(-time.Hour), + } + _ = st.CreateProject(ctx, project) + _ = st.CreateService(ctx, service) + automation := &domain.PluginAutomation{ + ID: domain.NewID(), ServiceID: service.ID, Name: "nightly", TriggerKind: "cron", + PromptTemplate: "work", Enabled: true, CreatedAt: now.Add(-10 * time.Minute), + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, nil, &domain.CronTrigger{ + AutomationID: automation.ID, CronExpr: "*/5 * * * *", + }); err != nil { + t.Fatal(err) + } + poller := NewPoller(st, okModels(), fakeHostGate{ + allowed: false, host: "blocked.example", + }, testLogger(), time.Minute) + poller.now = func() time.Time { return now } + poller.Tick(ctx) + + if runs := runsFor(t, st, service.ID); len(runs) != 0 { + t.Fatalf("runs=%d want 0", len(runs)) + } + executions, err := st.ListAutomationExecutions(ctx, automation.ID, "blocked", nil, "", 20) + if err != nil || len(executions) != 1 || + executions[0].ReasonCode != "host_not_allowed" || + executions[0].RepairRole != "cluster_admin" { + t.Fatalf("executions=%+v err=%v", executions, err) + } +} + +func TestAutomationRuleActorUsesCloudUserKind(t *testing.T) { + actor := automationRuleActor(context.Background(), store.NewMemStore(), "former-user") + if actor.Kind != "cloud_user" || actor.ID != "former-user" { + t.Fatalf("actor=%+v", actor) + } +} + +type fakeCardMaterializer struct { + calls int + allowSave []bool +} + +func (f *fakeCardMaterializer) Materialize(_ context.Context, execution domain.AutomationExecution, allowSave bool) domain.AutomationCardMaterializationResult { + f.calls++ + f.allowSave = append(f.allowSave, allowSave) + return domain.AutomationCardMaterializationResult{ + CardAutomationID: "kanban", WorkspaceID: "ws", DocumentID: "doc", + DocumentPath: execution.CardPath, CardState: "bound", + } +} + +func TestPluginCronCreateCardUsesLedgerWithoutDirectRun(t *testing.T) { + now := time.Date(2026, 7, 9, 15, 0, 0, 0, time.UTC) + st := store.NewMemStore() + ctx := context.Background() + project := &domain.Project{ID: domain.NewID(), Name: "p", CreatedAt: now.Add(-time.Hour)} + service := &domain.Service{ + ID: domain.NewID(), ProjectID: project.ID, Name: "svc", + RepoKind: domain.RepoKindRaw, RawRepoURL: "u", DefaultBranch: "main", + CreatedAt: now.Add(-time.Hour), + } + _ = st.CreateProject(ctx, project) + _ = st.CreateService(ctx, service) + automation := &domain.PluginAutomation{ + ID: domain.NewID(), ServiceID: service.ID, Name: "issue sweep", TriggerKind: "cron", + PromptTemplate: "triage the queue", Enabled: true, CreatedAt: now.Add(-10 * time.Minute), + } + if err := st.CreatePluginAutomation(ctx, automation, nil, nil, nil, &domain.CronTrigger{ + AutomationID: automation.ID, CronExpr: "*/5 * * * *", + OutputMode: domain.AutomationOutputCreateCard, + }); err != nil { + t.Fatal(err) + } + cards := &fakeCardMaterializer{} + poller := NewPoller(st, okModels(), nil, testLogger(), time.Minute).WithCardMaterializer(cards) + poller.now = func() time.Time { return now } + poller.Tick(ctx) + poller.Tick(ctx) + if got := runsFor(t, st, service.ID); len(got) != 0 { + t.Fatalf("direct runs=%d want 0", len(got)) + } + values, err := st.ListAutomationExecutions(ctx, automation.ID, "", nil, "", 20) + if err != nil || len(values) != 1 { + t.Fatalf("executions=%+v err=%v", values, err) + } + if values[0].CardState != "bound" || values[0].CardDocumentID != "doc" || + values[0].RunID != "" || !strings.Contains(values[0].CardPath, values[0].ID) { + t.Fatalf("execution=%+v", values[0]) + } + if cards.calls != 1 || len(cards.allowSave) != 1 || !cards.allowSave[0] { + t.Fatalf("materializer calls=%d allowSave=%v", cards.calls, cards.allowSave) + } +} + func TestPluginCronAutomationBlocksWhenReasoningCapabilityIsRemoved(t *testing.T) { now := time.Date(2026, 7, 9, 15, 0, 0, 0, time.UTC) st := store.NewMemStore() diff --git a/orchestrator/internal/store/automation_execution.go b/orchestrator/internal/store/automation_execution.go new file mode 100644 index 00000000..080a3b28 --- /dev/null +++ b/orchestrator/internal/store/automation_execution.go @@ -0,0 +1,566 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/cnjack/jcloud/internal/domain" +) + +const automationExecutionCols = `id,automation_id,automation_name,prompt_snapshot,project_id,service_id, + trigger_kind,event_key,state,outcome,output_mode,reason_code,reason_message,repair_role, + requested_actor,accountable_actor,run_id,external_url,card_automation_id,card_workspace_id,card_document_id, + card_path,card_state,writeback_state,writeback_error,created_at,updated_at,terminal_at` + +func normalizeAutomationExecution(value *domain.AutomationExecution) error { + if value == nil || strings.TrimSpace(value.ID) == "" || strings.TrimSpace(value.AutomationID) == "" || + strings.TrimSpace(value.ProjectID) == "" || strings.TrimSpace(value.ServiceID) == "" || + strings.TrimSpace(value.EventKey) == "" { + return errors.New("automation execution identity is required") + } + if !domain.ValidAutomationExecutionState(value.State) { + return errors.New("invalid automation execution state") + } + if value.OutputMode == "" { + value.OutputMode = domain.AutomationOutputRunOnly + } + if !domain.ValidAutomationOutputMode(value.OutputMode) { + return errors.New("invalid automation execution output mode") + } + switch value.TriggerKind { + case "scm", "cron", "manual": + default: + return errors.New("invalid automation execution trigger") + } + if value.CreatedAt.IsZero() { + value.CreatedAt = time.Now().UTC() + } + value.CreatedAt = value.CreatedAt.UTC() + if value.UpdatedAt.IsZero() { + value.UpdatedAt = value.CreatedAt + } + value.UpdatedAt = value.UpdatedAt.UTC() + return nil +} + +func scanAutomationExecution(row pgx.Row) (*domain.AutomationExecution, error) { + var value domain.AutomationExecution + var requestedActorJSON, accountableActorJSON []byte + err := row.Scan( + &value.ID, &value.AutomationID, &value.AutomationName, &value.PromptSnapshot, &value.ProjectID, &value.ServiceID, + &value.TriggerKind, &value.EventKey, &value.State, &value.Outcome, &value.OutputMode, + &value.ReasonCode, &value.ReasonMessage, &value.RepairRole, &requestedActorJSON, &accountableActorJSON, &value.RunID, + &value.ExternalURL, &value.CardAutomationID, &value.CardWorkspaceID, &value.CardDocumentID, + &value.CardPath, &value.CardState, &value.WritebackState, &value.WritebackError, + &value.CreatedAt, &value.UpdatedAt, &value.TerminalAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("scan automation execution: %w", err) + } + if len(requestedActorJSON) > 0 { + if err := json.Unmarshal(requestedActorJSON, &value.RequestedActor); err != nil { + return nil, fmt.Errorf("scan automation execution actor: %w", err) + } + } + if len(accountableActorJSON) > 0 { + if err := json.Unmarshal(accountableActorJSON, &value.AccountableActor); err != nil { + return nil, fmt.Errorf("scan automation execution accountable actor: %w", err) + } + } + return &value, nil +} + +func insertAutomationExecutionTx(ctx context.Context, tx pgx.Tx, value *domain.AutomationExecution) (bool, error) { + if err := normalizeAutomationExecution(value); err != nil { + return false, err + } + requestedActorJSON, err := json.Marshal(value.RequestedActor) + if err != nil { + return false, fmt.Errorf("encode automation execution actor: %w", err) + } + accountableActorJSON, err := json.Marshal(value.AccountableActor) + if err != nil { + return false, fmt.Errorf("encode automation execution accountable actor: %w", err) + } + tag, err := tx.Exec(ctx, `INSERT INTO automation_executions (`+automationExecutionCols+`) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28) + ON CONFLICT (automation_id,event_key) DO NOTHING`, + value.ID, value.AutomationID, value.AutomationName, value.PromptSnapshot, value.ProjectID, value.ServiceID, + value.TriggerKind, value.EventKey, value.State, value.Outcome, value.OutputMode, + value.ReasonCode, value.ReasonMessage, value.RepairRole, requestedActorJSON, accountableActorJSON, value.RunID, + value.ExternalURL, value.CardAutomationID, value.CardWorkspaceID, value.CardDocumentID, + value.CardPath, value.CardState, value.WritebackState, value.WritebackError, + value.CreatedAt, value.UpdatedAt, value.TerminalAt) + if err != nil { + return false, fmt.Errorf("insert automation execution: %w", err) + } + return tag.RowsAffected() == 1, nil +} + +func (s *PGStore) CreateAutomationExecution(ctx context.Context, value *domain.AutomationExecution, run *domain.Run) (*domain.AutomationExecution, bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, false, fmt.Errorf("create automation execution: begin: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + created, err := insertAutomationExecutionTx(ctx, tx, value) + if err != nil { + return nil, false, err + } + if !created { + existing, getErr := scanAutomationExecution(tx.QueryRow(ctx, + `SELECT `+automationExecutionCols+` FROM automation_executions WHERE automation_id=$1 AND event_key=$2`, + value.AutomationID, value.EventKey)) + if getErr != nil { + return nil, false, getErr + } + return existing, false, nil + } + if run != nil { + normalizeRunForCreate(run) + if run.CoalesceKey != "" { + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, run.CoalesceKey); err != nil { + return nil, false, fmt.Errorf("create automation execution: lock coalesce key: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE runs + SET status=$2,phase='Superseded',finished_at=COALESCE(finished_at,$3) + WHERE coalesce_key=$1 AND status=$4`, + run.CoalesceKey, domain.StatusCanceled, time.Now().UTC(), domain.StatusQueued); err != nil { + return nil, false, fmt.Errorf("create automation execution: supersede queued: %w", err) + } + } + if err := s.createRunTx(ctx, tx, run); err != nil { + return nil, false, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, false, fmt.Errorf("create automation execution: commit: %w", err) + } + copyValue := *value + return ©Value, true, nil +} + +func (s *PGStore) ClaimPluginCronExecution( + ctx context.Context, + automationID string, + previous, firedAt *time.Time, + execution *domain.AutomationExecution, + run *domain.Run, +) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("claim cron execution: begin: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + tag, err := tx.Exec(ctx, `UPDATE automation_cron_triggers + SET last_fired_at=$3,last_error=$4 + WHERE automation_id=$1 AND last_fired_at IS NOT DISTINCT FROM $2`, + automationID, previous, firedAt, execution.ReasonMessage) + if err != nil { + return false, fmt.Errorf("claim cron execution: advance: %w", err) + } + if tag.RowsAffected() == 0 { + return false, nil + } + if _, err := insertAutomationExecutionTx(ctx, tx, execution); err != nil { + return false, err + } + if run != nil { + normalizeRunForCreate(run) + if err := s.createRunTx(ctx, tx, run); err != nil { + return false, err + } + } + _, err = tx.Exec(ctx, `UPDATE automations_v2 + SET last_triggered_at=$2,last_run_id=$3,last_error=$4,updated_at=$2 + WHERE id=$1`, + automationID, firedAt, nullStr(execution.RunID), execution.ReasonMessage) + if err != nil { + return false, fmt.Errorf("claim cron execution: update automation: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("claim cron execution: commit: %w", err) + } + return true, nil +} + +func (s *PGStore) GetAutomationExecution(ctx context.Context, automationID, executionID string) (*domain.AutomationExecution, error) { + return scanAutomationExecution(s.pool.QueryRow(ctx, + `SELECT `+automationExecutionCols+` FROM automation_executions WHERE automation_id=$1 AND id=$2`, + automationID, executionID)) +} + +func (s *PGStore) GetAutomationExecutionByEventKey(ctx context.Context, automationID, eventKey string) (*domain.AutomationExecution, error) { + return scanAutomationExecution(s.pool.QueryRow(ctx, + `SELECT `+automationExecutionCols+` FROM automation_executions WHERE automation_id=$1 AND event_key=$2`, + automationID, eventKey)) +} + +func (s *PGStore) ListAutomationExecutions( + ctx context.Context, + automationID, state string, + beforeCreatedAt *time.Time, + beforeID string, + limit int, +) ([]domain.AutomationExecution, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + rows, err := s.pool.Query(ctx, `WITH projected AS ( + SELECT e.*, + COALESCE(NULLIF(e.run_id,''),NULLIF(o.run_id,''),'') AS projected_run_id + FROM automation_executions e + LEFT JOIN automation_kanban_claims c + ON c.automation_id=e.card_automation_id AND c.document_id=e.card_document_id + LEFT JOIN automation_kanban_occurrences o + ON o.id=c.latest_occurrence_id + ), filtered AS ( + SELECT p.*,r.status AS projected_run_status,r.phase AS projected_run_phase + FROM projected p + LEFT JOIN runs r ON r.id=p.projected_run_id + ) + SELECT `+automationExecutionCols+` + FROM filtered + WHERE automation_id=$1 + AND ($2='' OR ( + CASE + WHEN projected_run_status='canceled' AND projected_run_phase='Superseded' THEN 'superseded' + WHEN projected_run_status IN ('succeeded','failed','canceled') THEN 'terminal' + WHEN projected_run_status IN ('scheduling','running','awaiting_input') THEN 'running' + WHEN projected_run_status IS NOT NULL THEN 'queued' + ELSE state + END + )=$2) + AND ($3::timestamptz IS NULL OR (created_at,id) < ($3,$4)) + ORDER BY created_at DESC,id DESC LIMIT $5`, + automationID, state, beforeCreatedAt, beforeID, limit) + if err != nil { + return nil, fmt.Errorf("list automation executions: %w", err) + } + defer rows.Close() + out := []domain.AutomationExecution{} + for rows.Next() { + value, err := scanAutomationExecution(rows) + if err != nil { + return nil, err + } + out = append(out, *value) + } + return out, rows.Err() +} + +func (s *PGStore) ListPendingAutomationCards(ctx context.Context, limit int) ([]domain.AutomationExecution, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.pool.Query(ctx, `SELECT `+automationExecutionCols+` + FROM automation_executions + WHERE output_mode='create_card' AND card_state IN ('planned','creating') + ORDER BY updated_at,id LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("list pending automation cards: %w", err) + } + defer rows.Close() + out := []domain.AutomationExecution{} + for rows.Next() { + value, err := scanAutomationExecution(rows) + if err != nil { + return nil, err + } + out = append(out, *value) + } + return out, rows.Err() +} + +func (s *PGStore) ClaimAutomationCardCreation(ctx context.Context, executionID string) (bool, error) { + tag, err := s.pool.Exec(ctx, `UPDATE automation_executions + SET card_state='creating',updated_at=now() + WHERE id=$1 AND card_state='planned'`, executionID) + if err != nil { + return false, fmt.Errorf("claim automation card creation: %w", err) + } + return tag.RowsAffected() == 1, nil +} + +func (s *PGStore) UpdateAutomationExecutionCard( + ctx context.Context, + executionID, cardState, cardAutomationID, workspaceID, documentID, documentPath, + reasonCode, reasonMessage, repairRole string, +) error { + state := domain.AutomationExecutionAccepted + if reasonCode != "" { + state = domain.AutomationExecutionBlocked + } + tag, err := s.pool.Exec(ctx, `UPDATE automation_executions SET + state=$2,card_state=$3,card_automation_id=$4,card_workspace_id=$5, + card_document_id=$6,card_path=$7,reason_code=$8,reason_message=$9, + repair_role=$10,updated_at=now() + WHERE id=$1`, + executionID, state, cardState, cardAutomationID, workspaceID, documentID, + documentPath, reasonCode, reasonMessage, repairRole) + if err != nil { + return fmt.Errorf("update automation execution card: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (m *MemStore) CreateAutomationExecution(_ context.Context, value *domain.AutomationExecution, run *domain.Run) (*domain.AutomationExecution, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if err := normalizeAutomationExecution(value); err != nil { + return nil, false, err + } + for _, existing := range m.automationExecutions { + if existing.AutomationID == value.AutomationID && existing.EventKey == value.EventKey { + copyValue := existing + return ©Value, false, nil + } + } + if run != nil { + normalizeRunForCreate(run) + if err := m.validateRunForCreateLocked(run); err != nil { + return nil, false, err + } + } + copyValue := *value + m.automationExecutions[value.ID] = copyValue + if run != nil { + if run.CoalesceKey != "" { + now := time.Now().UTC() + for id, existing := range m.runs { + if existing.CoalesceKey != run.CoalesceKey || existing.Status != domain.StatusQueued { + continue + } + existing.Status = domain.StatusCanceled + existing.Phase = "Superseded" + if existing.FinishedAt == nil { + finishedAt := now + existing.FinishedAt = &finishedAt + } + m.runs[id] = existing + } + } + m.insertRunLocked(run) + } + return ©Value, true, nil +} + +func (m *MemStore) ClaimPluginCronExecution( + _ context.Context, + automationID string, + previous, firedAt *time.Time, + execution *domain.AutomationExecution, + run *domain.Run, +) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + cron, ok := m.pluginCronTriggers[automationID] + if !ok { + return false, ErrNotFound + } + if (cron.LastFiredAt == nil) != (previous == nil) || + (cron.LastFiredAt != nil && !cron.LastFiredAt.Equal(*previous)) { + return false, nil + } + if err := normalizeAutomationExecution(execution); err != nil { + return false, err + } + if run != nil { + normalizeRunForCreate(run) + if err := m.validateRunForCreateLocked(run); err != nil { + return false, err + } + } + cron.LastFiredAt = firedAt + cron.LastError = execution.ReasonMessage + m.pluginCronTriggers[automationID] = cron + m.automationExecutions[execution.ID] = *execution + if run != nil { + m.insertRunLocked(run) + } + if automation, ok := m.pluginAutomations[automationID]; ok { + automation.LastTriggeredAt = firedAt + automation.LastRunID = execution.RunID + automation.LastError = execution.ReasonMessage + automation.UpdatedAt = *firedAt + m.pluginAutomations[automationID] = automation + } + return true, nil +} + +func (m *MemStore) GetAutomationExecution(_ context.Context, automationID, executionID string) (*domain.AutomationExecution, error) { + m.mu.Lock() + defer m.mu.Unlock() + value, ok := m.automationExecutions[executionID] + if !ok || value.AutomationID != automationID { + return nil, ErrNotFound + } + return &value, nil +} + +func (m *MemStore) GetAutomationExecutionByEventKey(_ context.Context, automationID, eventKey string) (*domain.AutomationExecution, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, value := range m.automationExecutions { + if value.AutomationID == automationID && value.EventKey == eventKey { + copyValue := value + return ©Value, nil + } + } + return nil, ErrNotFound +} + +func (m *MemStore) ListAutomationExecutions( + _ context.Context, + automationID, state string, + beforeCreatedAt *time.Time, + beforeID string, + limit int, +) ([]domain.AutomationExecution, error) { + m.mu.Lock() + defer m.mu.Unlock() + if limit <= 0 || limit > 100 { + limit = 20 + } + out := []domain.AutomationExecution{} + for _, value := range m.automationExecutions { + projectedState := m.projectAutomationExecutionStateLocked(value) + if value.AutomationID != automationID || (state != "" && string(projectedState) != state) { + continue + } + if beforeCreatedAt != nil && !(value.CreatedAt.Before(*beforeCreatedAt) || + (value.CreatedAt.Equal(*beforeCreatedAt) && value.ID < beforeID)) { + continue + } + out = append(out, value) + } + 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) + }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemStore) projectAutomationExecutionStateLocked( + value domain.AutomationExecution, +) domain.AutomationExecutionState { + if run, ok := m.runs[value.RunID]; ok { + return projectedAutomationExecutionState(run) + } + if value.CardAutomationID == "" || value.CardDocumentID == "" { + return value.State + } + claim, ok := m.pluginKanbanClaims[pluginKanbanClaimKey(value.CardAutomationID, value.CardDocumentID)] + if !ok || claim.LatestOccurrenceID == "" { + return value.State + } + occurrence, ok := m.pluginKanbanOccurrences[claim.LatestOccurrenceID] + if !ok { + return value.State + } + if run, ok := m.runs[occurrence.RunID]; ok { + return projectedAutomationExecutionState(run) + } + return value.State +} + +func projectedAutomationExecutionState(run domain.Run) domain.AutomationExecutionState { + switch { + case run.Status == domain.StatusCanceled && run.Phase == "Superseded": + return domain.AutomationExecutionSuperseded + case run.Status.Terminal(): + return domain.AutomationExecutionTerminal + case run.Status == domain.StatusRunning || run.Status == domain.StatusAwaitingInput || + run.Status == domain.StatusScheduling: + return domain.AutomationExecutionRunning + default: + return domain.AutomationExecutionQueued + } +} + +func (m *MemStore) ListPendingAutomationCards(_ context.Context, limit int) ([]domain.AutomationExecution, error) { + m.mu.Lock() + defer m.mu.Unlock() + if limit <= 0 || limit > 100 { + limit = 50 + } + out := []domain.AutomationExecution{} + for _, value := range m.automationExecutions { + if value.OutputMode == domain.AutomationOutputCreateCard && + (value.CardState == "planned" || value.CardState == "creating") { + out = append(out, value) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].UpdatedAt.Equal(out[j].UpdatedAt) { + return out[i].ID < out[j].ID + } + return out[i].UpdatedAt.Before(out[j].UpdatedAt) + }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemStore) ClaimAutomationCardCreation(_ context.Context, executionID string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + value, ok := m.automationExecutions[executionID] + if !ok { + return false, ErrNotFound + } + if value.CardState != "planned" { + return false, nil + } + value.CardState = "creating" + value.UpdatedAt = time.Now().UTC() + m.automationExecutions[executionID] = value + return true, nil +} + +func (m *MemStore) UpdateAutomationExecutionCard( + _ context.Context, + executionID, cardState, cardAutomationID, workspaceID, documentID, documentPath, + reasonCode, reasonMessage, repairRole string, +) error { + m.mu.Lock() + defer m.mu.Unlock() + value, ok := m.automationExecutions[executionID] + if !ok { + return ErrNotFound + } + value.State = domain.AutomationExecutionAccepted + if reasonCode != "" { + value.State = domain.AutomationExecutionBlocked + } + value.CardState = cardState + value.CardAutomationID = cardAutomationID + value.CardWorkspaceID = workspaceID + value.CardDocumentID = documentID + value.CardPath = documentPath + value.ReasonCode = reasonCode + value.ReasonMessage = reasonMessage + value.RepairRole = repairRole + value.UpdatedAt = time.Now().UTC() + m.automationExecutions[executionID] = value + return nil +} diff --git a/orchestrator/internal/store/automation_execution_test.go b/orchestrator/internal/store/automation_execution_test.go new file mode 100644 index 00000000..0a7eef36 --- /dev/null +++ b/orchestrator/internal/store/automation_execution_test.go @@ -0,0 +1,190 @@ +package store + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cnjack/jcloud/internal/domain" +) + +func TestAutomationExecutionManualReplayCreatesOneRun(t *testing.T) { + ctx := context.Background() + st := NewMemStore() + now := time.Now().UTC().Truncate(time.Second) + project := &domain.Project{ID: domain.NewID(), Name: "ledger", CreatedAt: now} + service := &domain.Service{ + ID: domain.NewID(), ProjectID: project.ID, Name: "svc", + RepoKind: domain.RepoKindRaw, RawRepoURL: "u", DefaultBranch: "main", CreatedAt: now, + } + if err := st.CreateProject(ctx, project); err != nil { + t.Fatal(err) + } + if err := st.CreateService(ctx, service); err != nil { + t.Fatal(err) + } + const callers = 24 + var created atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + executionID, runID := domain.NewID(), domain.NewID() + value := &domain.AutomationExecution{ + ID: executionID, AutomationID: "automation", AutomationName: "Nightly", + ProjectID: project.ID, ServiceID: service.ID, TriggerKind: "manual", + EventKey: "manual:same-key", State: domain.AutomationExecutionQueued, + OutputMode: domain.AutomationOutputRunOnly, RunID: runID, + CreatedAt: now, UpdatedAt: now, + } + run := &domain.Run{ + ID: runID, ProjectID: project.ID, ServiceID: service.ID, + Prompt: "work", Status: domain.StatusQueued, Phase: "Queued", + OriginEventKey: value.EventKey, Attempt: 1, CreatedAt: now, + } + _, won, err := st.CreateAutomationExecution(ctx, value, run) + if err != nil { + t.Errorf("create: %v", err) + return + } + if won { + created.Add(1) + } + }() + } + wg.Wait() + if got := created.Load(); got != 1 { + t.Fatalf("created=%d want 1", got) + } + values, err := st.ListAutomationExecutions(ctx, "automation", "", nil, "", 20) + if err != nil || len(values) != 1 { + t.Fatalf("executions=%+v err=%v", values, err) + } + runs, err := st.ListRunsByService(ctx, service.ID, -1) + if err != nil || len(runs) != 1 || runs[0].ID != values[0].RunID { + t.Fatalf("runs=%+v execution=%+v err=%v", runs, values[0], err) + } +} + +func TestAutomationExecutionPaginationAndStateStayScoped(t *testing.T) { + ctx := context.Background() + st := NewMemStore() + now := time.Now().UTC().Truncate(time.Second) + for _, value := range []domain.AutomationExecution{ + {ID: "e3", AutomationID: "a1", AutomationName: "A", ProjectID: "p", ServiceID: "s", TriggerKind: "scm", EventKey: "3", State: domain.AutomationExecutionBlocked, OutputMode: domain.AutomationOutputRunOnly, CreatedAt: now, UpdatedAt: now}, + {ID: "e2", AutomationID: "a1", AutomationName: "A", ProjectID: "p", ServiceID: "s", TriggerKind: "scm", EventKey: "2", State: domain.AutomationExecutionQueued, OutputMode: domain.AutomationOutputRunOnly, CreatedAt: now, UpdatedAt: now}, + {ID: "e1", AutomationID: "a2", AutomationName: "B", ProjectID: "p", ServiceID: "s", TriggerKind: "scm", EventKey: "1", State: domain.AutomationExecutionBlocked, OutputMode: domain.AutomationOutputRunOnly, CreatedAt: now, UpdatedAt: now}, + } { + copyValue := value + if _, _, err := st.CreateAutomationExecution(ctx, ©Value, nil); err != nil { + t.Fatal(err) + } + } + first, err := st.ListAutomationExecutions(ctx, "a1", "", nil, "", 1) + if err != nil || len(first) != 1 || first[0].ID != "e3" { + t.Fatalf("first=%+v err=%v", first, err) + } + second, err := st.ListAutomationExecutions(ctx, "a1", "", &first[0].CreatedAt, first[0].ID, 1) + if err != nil || len(second) != 1 || second[0].ID != "e2" { + t.Fatalf("second=%+v err=%v", second, err) + } + blocked, err := st.ListAutomationExecutions(ctx, "a1", "blocked", nil, "", 20) + if err != nil || len(blocked) != 1 || blocked[0].ID != "e3" { + t.Fatalf("blocked=%+v err=%v", blocked, err) + } +} + +func TestAutomationExecutionStateFilterUsesLinkedRunProjection(t *testing.T) { + ctx := context.Background() + st := NewMemStore() + now := time.Now().UTC().Truncate(time.Second) + project := &domain.Project{ID: domain.NewID(), Name: "ledger", CreatedAt: now} + service := &domain.Service{ + ID: domain.NewID(), ProjectID: project.ID, Name: "svc", + RepoKind: domain.RepoKindRaw, RawRepoURL: "u", DefaultBranch: "main", CreatedAt: now, + } + if err := st.CreateProject(ctx, project); err != nil { + t.Fatal(err) + } + if err := st.CreateService(ctx, service); err != nil { + t.Fatal(err) + } + run := &domain.Run{ + ID: domain.NewID(), ProjectID: project.ID, ServiceID: service.ID, + Prompt: "work", Status: domain.StatusQueued, Phase: "Queued", + Attempt: 1, CreatedAt: now, + } + execution := &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: "automation", AutomationName: "Nightly", + ProjectID: project.ID, ServiceID: service.ID, TriggerKind: "manual", + EventKey: "manual:projected-state", State: domain.AutomationExecutionQueued, + OutputMode: domain.AutomationOutputRunOnly, RunID: run.ID, + CreatedAt: now, UpdatedAt: now, + } + if _, _, err := st.CreateAutomationExecution(ctx, execution, run); err != nil { + t.Fatal(err) + } + if _, err := st.MarkFailed(ctx, run.ID, "Failed", domain.FailureAgentError, "boom", now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + terminal, err := st.ListAutomationExecutions(ctx, execution.AutomationID, "terminal", nil, "", 20) + if err != nil || len(terminal) != 1 || terminal[0].ID != execution.ID { + t.Fatalf("terminal=%+v err=%v", terminal, err) + } + queued, err := st.ListAutomationExecutions(ctx, execution.AutomationID, "queued", nil, "", 20) + if err != nil || len(queued) != 0 { + t.Fatalf("queued=%+v err=%v", queued, err) + } +} + +func TestAutomationExecutionStateFilterUsesCardRunProjection(t *testing.T) { + ctx := context.Background() + st, cardAutomation, trigger := seedPluginKanbanOccurrenceStore(t) + now := time.Now().UTC().Truncate(time.Second) + execution := &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: "cron-automation", AutomationName: "Nightly card", + ProjectID: "project", ServiceID: cardAutomation.ServiceID, TriggerKind: "cron", + EventKey: "cron:card-projected-state", State: domain.AutomationExecutionAccepted, + OutputMode: domain.AutomationOutputCreateCard, + CardAutomationID: cardAutomation.ID, CardWorkspaceID: "workspace", + CardDocumentID: "card", CardPath: "jcode-automation/cron-automation/execution.md", + CardState: "bound", CreatedAt: now, UpdatedAt: now, + } + if _, _, err := st.CreateAutomationExecution(ctx, execution, nil); err != nil { + t.Fatal(err) + } + observed, err := st.ObservePluginKanbanCard(ctx, PluginKanbanObservation{ + AutomationID: cardAutomation.ID, ServiceID: cardAutomation.ServiceID, + InstallationID: trigger.InstallationID, WorkspaceID: "workspace", + DocumentID: execution.CardDocumentID, DocumentPath: execution.CardPath, + TriggerColumn: trigger.TriggerColumn, DoneColumn: trigger.DoneColumn, + ObservedColumn: trigger.TriggerColumn, EventKey: "card:event:" + domain.NewID(), + ObservedAt: now, + }) + if err != nil || observed.Occurrence == nil { + t.Fatalf("observe=%+v err=%v", observed, err) + } + run := &domain.Run{ + ID: domain.NewID(), ProjectID: execution.ProjectID, ServiceID: execution.ServiceID, + Prompt: "work", Status: domain.StatusQueued, Phase: "Queued", + Origin: domain.RunOriginKanban, OriginAutomationID: cardAutomation.ID, + OriginEventKey: observed.Occurrence.EventKey, Attempt: 1, CreatedAt: now, + } + if attached, err := st.CreatePluginKanbanOccurrenceRun(ctx, observed.Occurrence.ID, run); err != nil || !attached { + t.Fatalf("attach=%v err=%v", attached, err) + } + if _, err := st.MarkFailed(ctx, run.ID, "Failed", domain.FailureAgentError, "boom", now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + terminal, err := st.ListAutomationExecutions(ctx, execution.AutomationID, "terminal", nil, "", 20) + if err != nil || len(terminal) != 1 || terminal[0].ID != execution.ID { + t.Fatalf("terminal=%+v err=%v", terminal, err) + } + accepted, err := st.ListAutomationExecutions(ctx, execution.AutomationID, "accepted", nil, "", 20) + if err != nil || len(accepted) != 0 { + t.Fatalf("accepted=%+v err=%v", accepted, err) + } +} diff --git a/orchestrator/internal/store/memory.go b/orchestrator/internal/store/memory.go index da19560f..2679943b 100644 --- a/orchestrator/internal/store/memory.go +++ b/orchestrator/internal/store/memory.go @@ -45,6 +45,7 @@ type MemStore struct { pluginKanbanClaims map[string]domain.PluginKanbanClaim pluginKanbanOccurrences map[string]domain.PluginKanbanOccurrence pluginCronTriggers map[string]domain.CronTrigger + automationExecutions map[string]domain.AutomationExecution webhookReceipts map[string]domain.WebhookReceipt // provider|delivery id webhookReceiptDigests map[string]string // provider|authenticated payload digest -> receipt key runPluginSnapshots map[string]map[string]domain.RunPluginSnapshot @@ -104,6 +105,7 @@ func NewMemStore() *MemStore { pluginKanbanClaims: map[string]domain.PluginKanbanClaim{}, pluginKanbanOccurrences: map[string]domain.PluginKanbanOccurrence{}, pluginCronTriggers: map[string]domain.CronTrigger{}, + automationExecutions: map[string]domain.AutomationExecution{}, webhookReceipts: map[string]domain.WebhookReceipt{}, webhookReceiptDigests: map[string]string{}, runPluginSnapshots: map[string]map[string]domain.RunPluginSnapshot{}, @@ -213,6 +215,11 @@ func (m *MemStore) DeleteProject(_ context.Context, id string) error { m.deleteRunLocked(rid) } } + for executionID, execution := range m.automationExecutions { + if execution.ProjectID == id { + delete(m.automationExecutions, executionID) + } + } // Project plugin installations are children of projects. The database keeps // receipt/audit history but clears the removed installation reference. for installationID, installation := range m.pluginInstallations { @@ -3406,15 +3413,29 @@ func (m *MemStore) ClaimWebhookReceipt(_ context.Context, r *domain.WebhookRecei m.mu.Lock() defer m.mu.Unlock() key := string(r.Provider) + "|" + r.DeliveryID - if _, ok := m.webhookReceipts[key]; ok { - return false, nil - } digestKey := "" if r.PayloadDigest != "" && (r.Provider == domain.PluginGitHub || r.Provider == domain.PluginGitea) { digestKey = string(r.Provider) + "|" + r.PayloadDigest - if _, ok := m.webhookReceiptDigests[digestKey]; ok { + } + existingKey := key + existing, exists := m.webhookReceipts[existingKey] + if !exists && digestKey != "" { + if resolvedKey, ok := m.webhookReceiptDigests[digestKey]; ok { + existingKey = resolvedKey + existing, exists = m.webhookReceipts[existingKey] + } + } + if exists { + if existing.Status != "error" { return false, nil } + delete(m.webhookReceipts, existingKey) + if existing.PayloadDigest != "" { + oldDigestKey := string(existing.Provider) + "|" + existing.PayloadDigest + if m.webhookReceiptDigests[oldDigestKey] == existingKey { + delete(m.webhookReceiptDigests, oldDigestKey) + } + } } cp := *r if cp.ReceivedAt.IsZero() { diff --git a/orchestrator/internal/store/migrations/0061_automation_execution_ledger.sql b/orchestrator/internal/store/migrations/0061_automation_execution_ledger.sql new file mode 100644 index 00000000..cd16413e --- /dev/null +++ b/orchestrator/internal/store/migrations/0061_automation_execution_ledger.sql @@ -0,0 +1,48 @@ +ALTER TABLE automation_cron_triggers + ADD COLUMN IF NOT EXISTS output_mode TEXT NOT NULL DEFAULT 'run_only'; + +ALTER TABLE automation_cron_triggers + DROP CONSTRAINT IF EXISTS automation_cron_triggers_output_mode_check; +ALTER TABLE automation_cron_triggers + ADD CONSTRAINT automation_cron_triggers_output_mode_check + CHECK (output_mode IN ('run_only','create_card')); + +CREATE TABLE IF NOT EXISTS automation_executions ( + id TEXT PRIMARY KEY, + automation_id TEXT NOT NULL, + automation_name TEXT NOT NULL, + prompt_snapshot TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + service_id TEXT NOT NULL, + trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('scm','cron','manual')), + event_key TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ( + 'accepted','ignored','duplicate','superseded', + 'blocked','queued','running','terminal')), + outcome TEXT NOT NULL DEFAULT '', + output_mode TEXT NOT NULL CHECK (output_mode IN ('run_only','create_card')), + reason_code TEXT NOT NULL DEFAULT '', + reason_message TEXT NOT NULL DEFAULT '', + repair_role TEXT NOT NULL DEFAULT '', + requested_actor JSONB NOT NULL DEFAULT '{}'::jsonb, + accountable_actor JSONB NOT NULL DEFAULT '{}'::jsonb, + run_id TEXT NOT NULL DEFAULT '', + external_url TEXT NOT NULL DEFAULT '', + card_automation_id TEXT NOT NULL DEFAULT '', + card_workspace_id TEXT NOT NULL DEFAULT '', + card_document_id TEXT NOT NULL DEFAULT '', + card_path TEXT NOT NULL DEFAULT '', + card_state TEXT NOT NULL DEFAULT '', + writeback_state TEXT NOT NULL DEFAULT '', + writeback_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + terminal_at TIMESTAMPTZ, + UNIQUE (automation_id, event_key) +); + +CREATE INDEX IF NOT EXISTS automation_executions_history_idx + ON automation_executions (automation_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS automation_executions_card_pending_idx + ON automation_executions (card_state, updated_at) + WHERE output_mode = 'create_card' AND card_state IN ('planned','creating'); diff --git a/orchestrator/internal/store/pg_test.go b/orchestrator/internal/store/pg_test.go index 58243bef..290f2544 100644 --- a/orchestrator/internal/store/pg_test.go +++ b/orchestrator/internal/store/pg_test.go @@ -160,6 +160,134 @@ func TestPGRunProvenanceRoundTrip(t *testing.T) { } } +func TestPGAutomationExecutionRoundTrip(t *testing.T) { + ctx := context.Background() + st, seedRunID := pgTestStore(t) + seed, err := st.GetRun(ctx, seedRunID) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Microsecond) + value := &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: "deleted-automation", AutomationName: "Frozen name", + PromptSnapshot: "configured prompt", ProjectID: seed.ProjectID, ServiceID: seed.ServiceID, + TriggerKind: "manual", EventKey: "manual:" + domain.NewID(), + State: domain.AutomationExecutionBlocked, OutputMode: domain.AutomationOutputCreateCard, + ReasonCode: "jtype_unavailable", ReasonMessage: "Reconnect JType.", RepairRole: "project_owner", + RequestedActor: domain.ProvenanceActorRef{Kind: "external_actor", ID: "42", Label: "Mei", Provider: "jtype"}, + AccountableActor: domain.ProvenanceActorRef{Kind: "cloud_user", ID: "owner", Label: "Jack"}, + CardWorkspaceID: "ws", CardPath: "jcode-automation/a/e.md", CardState: "unavailable", + WritebackState: "pending", CreatedAt: now, UpdatedAt: now, + } + saved, created, err := st.CreateAutomationExecution(ctx, value, nil) + if err != nil || !created || saved.ID != value.ID { + t.Fatalf("saved=%+v created=%v err=%v", saved, created, err) + } + got, err := st.GetAutomationExecution(ctx, value.AutomationID, value.ID) + if err != nil { + t.Fatal(err) + } + if got.AutomationName != "Frozen name" || got.PromptSnapshot != "configured prompt" || + got.RequestedActor.Label != "Mei" || got.AccountableActor.Label != "Jack" || + got.CardPath != value.CardPath || got.ReasonCode != "jtype_unavailable" { + t.Fatalf("round trip=%+v", got) + } + + run := &domain.Run{ + ID: domain.NewID(), ProjectID: seed.ProjectID, ServiceID: seed.ServiceID, + Prompt: "project state", Status: domain.StatusQueued, Phase: "Queued", + Attempt: 1, CreatedAt: now, + } + projected := &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: "projected-" + domain.NewID(), AutomationName: "Projected", + ProjectID: seed.ProjectID, ServiceID: seed.ServiceID, TriggerKind: "manual", + EventKey: "manual:" + domain.NewID(), State: domain.AutomationExecutionQueued, + OutputMode: domain.AutomationOutputRunOnly, RunID: run.ID, + CreatedAt: now, UpdatedAt: now, + } + if _, _, err := st.CreateAutomationExecution(ctx, projected, run); err != nil { + t.Fatal(err) + } + if _, err := st.MarkFailed(ctx, run.ID, "Failed", domain.FailureAgentError, "boom", now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + terminal, err := st.ListAutomationExecutions(ctx, projected.AutomationID, "terminal", nil, "", 20) + if err != nil || len(terminal) != 1 || terminal[0].ID != projected.ID { + t.Fatalf("terminal=%+v err=%v", terminal, err) + } +} + +func TestPGAutomationExecutionStateFilterUsesCardRunProjection(t *testing.T) { + ctx := context.Background() + st, seedRunID := pgTestStore(t) + seed, err := st.GetRun(ctx, seedRunID) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Microsecond) + installation := &domain.PluginInstallation{ + ID: domain.NewID(), ProjectID: seed.ProjectID, Provider: domain.PluginJType, + Status: domain.PluginStatusEnabled, WorkspaceID: "workspace", Scopes: []string{}, + } + if err := st.CreatePluginInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + cardAutomation := &domain.PluginAutomation{ + ID: domain.NewID(), ServiceID: seed.ServiceID, InstallationID: installation.ID, + Name: "Agent queue", TriggerKind: "kanban", RunKind: domain.RunKindAgent, Enabled: true, + } + trigger := &domain.KanbanTrigger{ + AutomationID: cardAutomation.ID, InstallationID: installation.ID, + BoardRef: "delivery", TriggerColumn: "agent", DoneColumn: "done", + } + if err := st.CreatePluginAutomation(ctx, cardAutomation, nil, nil, trigger, nil); err != nil { + t.Fatal(err) + } + execution := &domain.AutomationExecution{ + ID: domain.NewID(), AutomationID: "cron-" + domain.NewID(), AutomationName: "Nightly card", + ProjectID: seed.ProjectID, ServiceID: seed.ServiceID, TriggerKind: "cron", + EventKey: "cron:card-projected-state", State: domain.AutomationExecutionAccepted, + OutputMode: domain.AutomationOutputCreateCard, + CardAutomationID: cardAutomation.ID, CardWorkspaceID: installation.WorkspaceID, + CardDocumentID: "card", CardPath: "jcode-automation/cron/execution.md", + CardState: "bound", CreatedAt: now, UpdatedAt: now, + } + if _, _, err := st.CreateAutomationExecution(ctx, execution, nil); err != nil { + t.Fatal(err) + } + observed, err := st.ObservePluginKanbanCard(ctx, PluginKanbanObservation{ + AutomationID: cardAutomation.ID, ServiceID: seed.ServiceID, + InstallationID: installation.ID, WorkspaceID: installation.WorkspaceID, + DocumentID: execution.CardDocumentID, DocumentPath: execution.CardPath, + TriggerColumn: trigger.TriggerColumn, DoneColumn: trigger.DoneColumn, + ObservedColumn: trigger.TriggerColumn, EventKey: "card:event:" + domain.NewID(), + ObservedAt: now, + }) + if err != nil || observed.Occurrence == nil { + t.Fatalf("observe=%+v err=%v", observed, err) + } + run := &domain.Run{ + ID: domain.NewID(), ProjectID: seed.ProjectID, ServiceID: seed.ServiceID, + Prompt: "work", Status: domain.StatusQueued, Phase: "Queued", + Origin: domain.RunOriginKanban, OriginAutomationID: cardAutomation.ID, + OriginEventKey: observed.Occurrence.EventKey, Attempt: 1, CreatedAt: now, + } + if attached, err := st.CreatePluginKanbanOccurrenceRun(ctx, observed.Occurrence.ID, run); err != nil || !attached { + t.Fatalf("attach=%v err=%v", attached, err) + } + if _, err := st.MarkFailed(ctx, run.ID, "Failed", domain.FailureAgentError, "boom", now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + terminal, err := st.ListAutomationExecutions(ctx, execution.AutomationID, "terminal", nil, "", 20) + if err != nil || len(terminal) != 1 || terminal[0].ID != execution.ID { + t.Fatalf("terminal=%+v err=%v", terminal, err) + } + accepted, err := st.ListAutomationExecutions(ctx, execution.AutomationID, "accepted", nil, "", 20) + if err != nil || len(accepted) != 0 { + t.Fatalf("accepted=%+v err=%v", accepted, err) + } +} + func TestPGWebhookBindingSecretAndAuthenticatedDigest(t *testing.T) { ctx := context.Background() st, runID := pgTestStore(t) @@ -204,6 +332,19 @@ func TestPGWebhookBindingSecretAndAuthenticatedDigest(t *testing.T) { if claimed, err := st.ClaimWebhookReceipt(ctx, &replay); err != nil || claimed { t.Fatalf("claim replay=%v err=%v; want duplicate", claimed, err) } + first.Status = "error" + first.Error = "ledger unavailable" + if err := st.CompleteWebhookReceipt(ctx, first); err != nil { + t.Fatal(err) + } + replay.Status = "received" + replay.Error = "" + if claimed, err := st.ClaimWebhookReceipt(ctx, &replay); err != nil || !claimed { + t.Fatalf("reclaim errored receipt=%v err=%v", claimed, err) + } + if claimed, err := st.ClaimWebhookReceipt(ctx, &replay); err != nil || claimed { + t.Fatalf("second reclaim=%v err=%v; want duplicate", claimed, err) + } } // TestPGKanbanClaimNullableAndWritebackScan covers two PostgreSQL-only contracts diff --git a/orchestrator/internal/store/plugin_validation.go b/orchestrator/internal/store/plugin_validation.go index 89c325e4..9d228872 100644 --- a/orchestrator/internal/store/plugin_validation.go +++ b/orchestrator/internal/store/plugin_validation.go @@ -69,6 +69,12 @@ func validatePluginAutomationAggregate(a *domain.PluginAutomation, scm *domain.S if cron == nil || cron.AutomationID != a.ID { return fmt.Errorf("cron automation needs exactly one trigger") } + if cron.OutputMode == "" { + cron.OutputMode = domain.AutomationOutputRunOnly + } + if !domain.ValidAutomationOutputMode(cron.OutputMode) { + return fmt.Errorf("invalid cron automation output mode") + } } return nil } diff --git a/orchestrator/internal/store/plugins.go b/orchestrator/internal/store/plugins.go index aef47239..683dd223 100644 --- a/orchestrator/internal/store/plugins.go +++ b/orchestrator/internal/store/plugins.go @@ -529,7 +529,7 @@ func (s *PGStore) CreatePluginAutomation(ctx context.Context, a *domain.PluginAu } } if cron != nil { - if _, err = tx.Exec(ctx, `INSERT INTO automation_cron_triggers(automation_id,cron_expr)VALUES($1,$2)`, a.ID, cron.CronExpr); err != nil { + if _, err = tx.Exec(ctx, `INSERT INTO automation_cron_triggers(automation_id,cron_expr,output_mode)VALUES($1,$2,$3)`, a.ID, cron.CronExpr, cron.OutputMode); err != nil { return err } } @@ -574,7 +574,7 @@ func (s *PGStore) GetPluginAutomationSpec(ctx context.Context, id string) (*doma spec.Kanban = &v case "cron": var v domain.CronTrigger - if err := s.pool.QueryRow(ctx, `SELECT automation_id,cron_expr,last_fired_at,last_error FROM automation_cron_triggers WHERE automation_id=$1`, id).Scan(&v.AutomationID, &v.CronExpr, &v.LastFiredAt, &v.LastError); err != nil { + if err := s.pool.QueryRow(ctx, `SELECT automation_id,cron_expr,output_mode,last_fired_at,last_error FROM automation_cron_triggers WHERE automation_id=$1`, id).Scan(&v.AutomationID, &v.CronExpr, &v.OutputMode, &v.LastFiredAt, &v.LastError); err != nil { return nil, fmt.Errorf("get cron trigger: %w", err) } spec.Cron = &v @@ -996,7 +996,7 @@ func (s *PGStore) ReplacePluginAutomationSpec(ctx context.Context, a *domain.Plu } } if cron != nil { - if _, err = tx.Exec(ctx, `INSERT INTO automation_cron_triggers(automation_id,cron_expr)VALUES($1,$2)`, a.ID, cron.CronExpr); err != nil { + if _, err = tx.Exec(ctx, `INSERT INTO automation_cron_triggers(automation_id,cron_expr,output_mode)VALUES($1,$2,$3)`, a.ID, cron.CronExpr, cron.OutputMode); err != nil { return err } } @@ -1027,7 +1027,23 @@ func (s *PGStore) DeletePluginAutomation(ctx context.Context, id string) error { } func (s *PGStore) ClaimWebhookReceipt(ctx context.Context, r *domain.WebhookReceipt) (bool, error) { - tag, err := s.pool.Exec(ctx, `INSERT INTO webhook_receipts(id,provider,delivery_id,payload_digest,installation_id,event_family,action,external_actor_id,external_actor,object_ref,status,matched_automation_id,error,received_at)VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, r.ID, r.Provider, r.DeliveryID, r.PayloadDigest, nullStr(r.InstallationID), r.EventFamily, r.Action, r.ExternalActorID, r.ExternalActor, r.ObjectRef, r.Status, nullStr(r.MatchedAutomationID), r.Error, r.ReceivedAt) + tag, err := s.pool.Exec(ctx, `UPDATE webhook_receipts SET + delivery_id=$2,payload_digest=$3,installation_id=$4,event_family=$5,action=$6, + external_actor_id=$7,external_actor=$8,object_ref=$9,status=$10, + matched_automation_id=$11,error=$12,received_at=$13::timestamptz, + expires_at=$13::timestamptz + interval '30 days' + WHERE provider=$1 AND status='error' + AND (delivery_id=$2 OR ($3<>'' AND payload_digest=$3))`, + r.Provider, r.DeliveryID, r.PayloadDigest, nullStr(r.InstallationID), + r.EventFamily, r.Action, r.ExternalActorID, r.ExternalActor, r.ObjectRef, + r.Status, nullStr(r.MatchedAutomationID), r.Error, r.ReceivedAt) + if err != nil { + return false, fmt.Errorf("reclaim webhook receipt: %w", err) + } + if tag.RowsAffected() == 1 { + return true, nil + } + tag, err = s.pool.Exec(ctx, `INSERT INTO webhook_receipts(id,provider,delivery_id,payload_digest,installation_id,event_family,action,external_actor_id,external_actor,object_ref,status,matched_automation_id,error,received_at)VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, r.ID, r.Provider, r.DeliveryID, r.PayloadDigest, nullStr(r.InstallationID), r.EventFamily, r.Action, r.ExternalActorID, r.ExternalActor, r.ObjectRef, r.Status, nullStr(r.MatchedAutomationID), r.Error, r.ReceivedAt) if err != nil { return false, fmt.Errorf("claim webhook receipt: %w", err) } diff --git a/orchestrator/internal/store/plugins_test.go b/orchestrator/internal/store/plugins_test.go index fabc025d..e308abb1 100644 --- a/orchestrator/internal/store/plugins_test.go +++ b/orchestrator/internal/store/plugins_test.go @@ -337,6 +337,33 @@ func TestWebhookReceiptAuthenticatedPayloadDigestDeduplicatesAndExpires(t *testi } } +func TestWebhookReceiptErrorCanBeReclaimedExactlyOnce(t *testing.T) { + ctx, st := context.Background(), NewMemStore() + first := &domain.WebhookReceipt{ + ID: "first", Provider: domain.PluginGitea, DeliveryID: "delivery-1", + PayloadDigest: "authenticated-body", Status: "received", ReceivedAt: time.Now().UTC(), + } + if claimed, err := st.ClaimWebhookReceipt(ctx, first); err != nil || !claimed { + t.Fatalf("claim first = %v, %v", claimed, err) + } + first.Status = "error" + first.Error = "ledger unavailable" + if err := st.CompleteWebhookReceipt(ctx, first); err != nil { + t.Fatal(err) + } + retry := *first + retry.ID = "retry" + retry.DeliveryID = "delivery-2" + retry.Status = "received" + retry.Error = "" + if claimed, err := st.ClaimWebhookReceipt(ctx, &retry); err != nil || !claimed { + t.Fatalf("reclaim error = %v, %v", claimed, err) + } + if claimed, err := st.ClaimWebhookReceipt(ctx, &retry); err != nil || claimed { + t.Fatalf("second reclaim = %v, %v; want duplicate", claimed, err) + } +} + func TestPluginInstallationIsUniqueAndUninstallCascadesBoundService(t *testing.T) { ctx, st := context.Background(), NewMemStore() p := &domain.Project{ID: "p", Name: "project", CreatedAt: time.Now()} diff --git a/orchestrator/internal/store/store.go b/orchestrator/internal/store/store.go index 90780f7b..9079b99d 100644 --- a/orchestrator/internal/store/store.go +++ b/orchestrator/internal/store/store.go @@ -545,6 +545,14 @@ type Store interface { DeletePluginAutomation(ctx context.Context, id string) error ListEnabledCronAutomations(ctx context.Context) ([]domain.PluginAutomationSpec, error) AdvancePluginCronAutomation(ctx context.Context, id string, previous, firedAt *time.Time, lastError string) (bool, error) + CreateAutomationExecution(ctx context.Context, execution *domain.AutomationExecution, run *domain.Run) (*domain.AutomationExecution, bool, error) + ClaimPluginCronExecution(ctx context.Context, automationID string, previous, firedAt *time.Time, execution *domain.AutomationExecution, run *domain.Run) (bool, error) + GetAutomationExecution(ctx context.Context, automationID, executionID string) (*domain.AutomationExecution, error) + GetAutomationExecutionByEventKey(ctx context.Context, automationID, eventKey string) (*domain.AutomationExecution, error) + ListAutomationExecutions(ctx context.Context, automationID, state string, beforeCreatedAt *time.Time, beforeID string, limit int) ([]domain.AutomationExecution, error) + ListPendingAutomationCards(ctx context.Context, limit int) ([]domain.AutomationExecution, error) + ClaimAutomationCardCreation(ctx context.Context, executionID string) (bool, error) + UpdateAutomationExecutionCard(ctx context.Context, executionID, cardState, cardAutomationID, workspaceID, documentID, documentPath, reasonCode, reasonMessage, repairRole string) 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)