feat(app-dev): sync error hints, flatEntity labels, dev-mode summary UI, and docs (#21252)

Split out of #21240 — all remaining app-dev improvements. Stacked on
#21251 (review/merge that first).

- Actionable recovery hints on failed syncs; unified diff renderer;
`--dry-run` guard.
- Return `flatEntity` on update/delete sync actions and unify the diff
label.
- Summarize the dev-mode entity list unless `--verbose`.
- Docs: syncing & recovery guide + dry-run + open-an-issue prompt.
- Live execution mode for synced logic functions; clearer manifest
warnings.

<img width="1018" height="700" alt="image"
src="https://github.com/user-attachments/assets/5e9ce19e-0f1d-4f99-8524-4e118bde932b"
/>
This commit is contained in:
martmull
2026-06-08 17:43:28 +02:00
committed by GitHub
parent 13e8e26d1c
commit 77d1e8ced6
34 changed files with 716 additions and 292 deletions
@@ -42,7 +42,7 @@ export class DevModeOrchestrator {
private startWatchersStep: StartWatchersOrchestratorStep;
constructor(options: DevModeOrchestratorOptions) {
this.debounceMs = options.debounceMs ?? 2_000;
this.debounceMs = options.debounceMs ?? 1_000;
this.state = options.state;
this.verbose = options.verbose ?? false;
@@ -9,7 +9,7 @@ import {
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { getSyncErrorRecoveryHint } from '@/cli/utilities/error/get-sync-error-recovery-hint';
import { type Manifest } from 'twenty-shared/application';
export type SyncApplicationOrchestratorStepOutput = {
@@ -95,11 +95,17 @@ export class SyncApplicationOrchestratorStep {
});
} else {
events.push({
message: `Sync failed with error: ${serializeError(syncResult.error)}`,
message: `Sync failed with error: ${syncResult.message ?? 'Sync failed'}`,
status: 'error',
});
}
const recoveryHint = getSyncErrorRecoveryHint(syncResult.message);
if (recoveryHint) {
events.push({ message: recoveryHint, status: 'info' });
}
const summaryMessage = errorEvents ? errorEvents[0].message : 'Sync failed';
step.output = { syncStatus: 'error', error: summaryMessage };
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { type OrchestratorStateEntityInfo } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { summarizeEntityStatuses } from '@/cli/utilities/dev/ui/dev-ui-constants';
const entity = (
name: string,
status: OrchestratorStateEntityInfo['status'],
): OrchestratorStateEntityInfo => ({ name, path: name, status });
describe('summarizeEntityStatuses', () => {
it('counts every status in display order', () => {
const parts = summarizeEntityStatuses([
entity('a', 'success'),
entity('b', 'success'),
entity('c', 'error'),
entity('d', 'building'),
]);
expect(parts).toEqual([
{ status: 'success', count: 2, label: 'synced' },
{ status: 'building', count: 1, label: 'building' },
{ status: 'error', count: 1, label: 'error' },
]);
});
it('returns only the non-zero statuses', () => {
const parts = summarizeEntityStatuses([
entity('a', 'success'),
entity('b', 'success'),
]);
expect(parts).toEqual([{ status: 'success', count: 2, label: 'synced' }]);
});
});
@@ -15,6 +15,7 @@ import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks';
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
import {
DevUiEntitySection,
DevUiEntitySummary,
ENTITY_ORDER,
} from '@/cli/utilities/dev/ui/components/dev-ui-entity-section';
import { DevUiVersionRow } from '@/cli/utilities/dev/ui/components/dev-ui-version-row';
@@ -62,8 +63,10 @@ export const DevUiStepStatusLabel = ({
export const DevUiApplicationPanel = ({
state,
verbose = false,
}: {
state: OrchestratorState;
verbose?: boolean;
}): React.ReactElement => {
const { Box, Text } = useInk();
const groupedEntities = groupEntitiesByType(state.entities);
@@ -111,13 +114,17 @@ export const DevUiApplicationPanel = ({
</Box>
<Box marginLeft={2} flexDirection="column">
{ENTITY_ORDER.map((type) => {
const entities = groupedEntities.get(type) ?? [];
{verbose ? (
ENTITY_ORDER.map((type) => {
const entities = groupedEntities.get(type) ?? [];
return (
<DevUiEntitySection key={type} type={type} entities={entities} />
);
})}
return (
<DevUiEntitySection key={type} type={type} entities={entities} />
);
})
) : (
<DevUiEntitySummary entities={Array.from(state.entities.values())} />
)}
</Box>
</Box>
);
@@ -8,6 +8,7 @@ import {
UPLOAD_FRAMES,
mapFileStatusToDevUiStatus,
shortenPath,
summarizeEntityStatuses,
} from '@/cli/utilities/dev/ui/dev-ui-constants';
import { useStatusIcon } from '@/cli/utilities/dev/ui/dev-ui-hooks';
import { useInk } from '@/cli/utilities/dev/ui/dev-ui-ink-context';
@@ -67,6 +68,35 @@ export const DevUiEntitySection = ({
);
};
export const DevUiEntitySummary = ({
entities,
}: {
entities: OrchestratorStateEntityInfo[];
}): React.ReactElement | null => {
const { Box, Text } = useInk();
if (entities.length === 0) return null;
const parts = summarizeEntityStatuses(entities);
return (
<Box marginTop={1}>
<Text bold dimColor>
Entities{' '}
</Text>
{parts.map((part, index) => (
<Box key={part.status}>
{index > 0 && <Text dimColor> · </Text>}
<DevUiStatusIcon uiStatus={mapFileStatusToDevUiStatus(part.status)} />
<Text>
{part.count} {part.label}
</Text>
</Box>
))}
</Box>
);
};
export const DevUiEntityLegend = (): React.ReactElement => {
const { Box, Text } = useInk();
@@ -18,8 +18,10 @@ const SETTLE_DELAY_MS = 80;
const DevUI = ({
uiStateManager,
verbose,
}: {
uiStateManager: DevUiStateManager;
verbose: boolean;
}): React.ReactElement => {
const { Box, Static } = useInk();
@@ -85,8 +87,8 @@ const DevUI = ({
</Static>
<Box marginTop={1} flexDirection="column">
<DevUiApplicationPanel state={state} />
<DevUiEntityLegend />
<DevUiApplicationPanel state={state} verbose={verbose} />
{verbose && <DevUiEntityLegend />}
</Box>
</>
);
@@ -94,15 +96,15 @@ const DevUI = ({
export const renderDevUI = async (
uiStateManager: DevUiStateManager,
verbose = false,
): Promise<{ unmount: () => void }> => {
const ink = await import('ink');
const { render, Box, Text, Static } = ink;
const { unmount } = render(
<InkProvider value={{ Box, Text, Static }}>
<DevUI uiStateManager={uiStateManager} />
<DevUI uiStateManager={uiStateManager} verbose={verbose} />
</InkProvider>,
{ incrementalRendering: true },
);
return { unmount };
@@ -78,20 +78,9 @@ export const SYNC_STATUS_LABELS: Record<OrchestratorStateSyncStatus, string> = {
error: 'Error',
};
export const SPINNER_FRAMES = [
'⠋',
'⠙',
'⠹',
'⠸',
'⠼',
'⠴',
'⠦',
'⠧',
'⠇',
'⠏',
];
export const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
export const UPLOAD_FRAMES = ['↑', '⇡', '', ''];
export const UPLOAD_FRAMES = ['↑', '⇡', '', ''];
export const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.Object]: 'Objects',
@@ -158,6 +147,43 @@ export const groupEntitiesByType = (
return grouped;
};
export type DevUiEntityStatusSummaryPart = {
status: OrchestratorStateFileStatus;
count: number;
label: string;
};
const ENTITY_STATUS_SUMMARY_ORDER: {
status: OrchestratorStateFileStatus;
label: string;
}[] = [
{ status: 'success', label: 'synced' },
{ status: 'building', label: 'building' },
{ status: 'uploading', label: 'uploading' },
{ status: 'pending', label: 'pending' },
{ status: 'error', label: 'error' },
];
export const summarizeEntityStatuses = (
entities: OrchestratorStateEntityInfo[],
): DevUiEntityStatusSummaryPart[] => {
const counts: Record<OrchestratorStateFileStatus, number> = {
pending: 0,
building: 0,
uploading: 0,
success: 0,
error: 0,
};
for (const entity of entities) {
counts[entity.status] += 1;
}
return ENTITY_STATUS_SUMMARY_ORDER.filter(
({ status }) => counts[status] > 0,
).map(({ status, label }) => ({ status, count: counts[status], label }));
};
export const getApplicationUrl = (state: OrchestratorState): string | null => {
const applicationId = state.steps.resolveApplication.output.applicationId;