Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions console/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,6 +71,7 @@ export function App() {
<Route path="/projects/:projectId" element={<ProjectDetailPage />} />
<Route path="/projects/:projectId/plugins/:provider" element={<ProjectPluginDetailPage />} />
<Route path="/projects/:projectId/automations/new" element={<AutomationEditorPage />} />
<Route path="/projects/:projectId/automations/:automationId" element={<AutomationDetailPage />} />
<Route path="/projects/:projectId/automations/:automationId/edit" element={<AutomationEditorPage />} />
<Route path="/runs/:runId" element={<RunDetailPage />} />
<Route path="/devices" element={<DevicesPage />} />
Expand Down
17 changes: 17 additions & 0 deletions console/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import type {
PrInfo,
Project,
ProjectAutomationSpec,
AutomationExecution,
AutomationExecutionsPage,
ProjectPlugin,
PluginAuditEvent,
PluginConsentInput,
Expand Down Expand Up @@ -337,6 +339,9 @@ export interface ApiClient {
createProjectAutomation(projectId: string, input: CreateProjectAutomationInput): Promise<ProjectAutomationSpec>;
updateProjectAutomation(projectId: string, automationId: string, input: UpdateProjectAutomationInput): Promise<ProjectAutomationSpec>;
deleteProjectAutomation(projectId: string, automationId: string): Promise<void>;
listAutomationExecutions(automationId: string, before?: string, state?: string, limit?: number): Promise<AutomationExecutionsPage>;
getAutomationExecution(automationId: string, executionId: string): Promise<AutomationExecution>;
runAutomationNow(automationId: string, idempotencyKey: string): Promise<AutomationExecution>;
getServiceKanban(serviceId: string): Promise<ServiceKanbanBinding>;
getServiceKanbanPolicy(serviceId: string): Promise<ServiceKanbanPolicy>;
listServiceKanbanCardExecutions(serviceId: string, workspaceId: string, documentPath: string, before?: string, limit?: number): Promise<KanbanCardExecutionsPage>;
Expand Down Expand Up @@ -827,6 +832,18 @@ export function createHttpClient(
}),
deleteProjectAutomation: (_projectId, automationId) =>
req<void>(`/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<AutomationExecutionsPage>(`/automations/${encodeURIComponent(automationId)}/executions?${params.toString()}`);
},
getAutomationExecution: (automationId, executionId) =>
req<AutomationExecution>(`/automations/${encodeURIComponent(automationId)}/executions/${encodeURIComponent(executionId)}`),
runAutomationNow: (automationId, idempotencyKey) =>
req<AutomationExecution>(`/automations/${encodeURIComponent(automationId)}/executions`, {
method: 'POST', body: JSON.stringify({ idempotency_key: idempotencyKey }),
}),
getServiceKanban: (serviceId) => req<ServiceKanbanBinding>(`/services/${encodeURIComponent(serviceId)}/kanban`),
getServiceKanbanPolicy: (serviceId) => req<ServiceKanbanPolicy>(`/services/${encodeURIComponent(serviceId)}/kanban/policy`),
listServiceKanbanCardExecutions: (serviceId, workspaceId, documentPath, before, limit = 20) => {
Expand Down
62 changes: 62 additions & 0 deletions console/src/api/mockClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import type {
import type {
AddMemberInput,
ApiKey,
AutomationExecution,
AutomationExecutionsPage,
BoardEmbedLink,
CatalogModel,
ClusterProviderConfig,
Expand Down Expand Up @@ -262,6 +264,8 @@ export function createMockClient(): ApiClient {
],
};
const projectAutomations = new Map<string, ProjectAutomationSpec>();
const automationExecutions = new Map<string, AutomationExecution[]>();
const automationIdempotency = new Map<string, string>();
const projectPlugins = new Map<string, Map<ProviderKind, ProjectPlugin>>();
const clusterProviders = new Map<ProviderKind, ClusterProviderConfig>((['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' }]));

Expand Down Expand Up @@ -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<AutomationExecutionsPage> {
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<AutomationExecution> {
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<AutomationExecution> {
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<ProjectAutomationSpec> {
const spec = [...projectAutomations.values()].find((item) =>
item.automation.service_id === serviceId && item.automation.trigger_kind === 'kanban');
Expand Down
77 changes: 77 additions & 0 deletions console/src/api/queries.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 53 additions & 5 deletions console/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { useApi } from './ApiProvider';
import type {
AddMemberInput,
AutomationExecution,
CreateProjectAutomationInput,
CreateApiKeyInput,
CreateApiKeyResponse,
Expand All @@ -21,6 +22,7 @@ import type {
CreateRunInput,
CreateServiceInput,
ServiceBranch,
KanbanCardExecution,
Member,
Model,
Project,
Expand All @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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);
},
});
}
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading