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
9 changes: 9 additions & 0 deletions packages/host/app/commands/check-correctness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ export default class CheckCorrectnessCommand extends HostBaseCommand<
let errors: string[] = [];
let lintIssues: string[] = [];

let sizeLimitError = this.cardService.getSizeLimitError(input.targetRef);
if (sizeLimitError) {
return new CorrectnessResultCard({
correct: false,
errors: [sizeLimitError.message],
Comment on lines +81 to +85

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ensure size-limit errors are available before correctness

This early return relies on cardService.getSizeLimitError() being populated, but that map is only set when validateSizeLimit runs during the async persist flow. For AI patch commands that call store.patch with doNotWaitForPersist (e.g., PatchCardInstanceCommand/PatchCodeCommand), CheckCorrectnessCommand can run before the save reaches validation, so it will report correct: true even though the write later fails with a 413. Consider validating size synchronously before returning from the patch commands or ensuring correctness checks wait for the persist attempt to complete so the size-limit error is guaranteed to be recorded.

Useful? React with 👍 / 👎.

warnings: [],
});
}

if (
targetType === 'file' &&
(await this.isEmptyFileContent(input.targetRef))
Expand Down
27 changes: 22 additions & 5 deletions packages/host/app/components/operator-mode/code-editor.gts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ interface Signature {
onSetup: (
updateCursorByName: (name: string, fieldName?: string) => void,
) => void;
onWriteError?: (message: string | undefined) => void;
};
}

Expand Down Expand Up @@ -350,6 +351,7 @@ export default class CodeEditor extends Component<Signature> {
this.syncWithStore.perform(content);

await timeout(this.environmentService.autoSaveDelayMs);
this.args.onWriteError?.(undefined);
this.writeSourceCodeToFile(
this.args.file,
content,
Expand Down Expand Up @@ -433,7 +435,11 @@ export default class CodeEditor extends Component<Signature> {
private waitForSourceCodeWrite = restartableTask(async () => {
if (isReady(this.args.file)) {
this.args.onFileSave('started');
await all([this.args.file.writing, timeout(500)]);
try {
await all([this.args.file.writing, timeout(500)]);
} catch {
// Errors are surfaced via writeError banners, so don't rethrow.
}
this.args.onFileSave('finished');
}
});
Expand All @@ -459,10 +465,21 @@ export default class CodeEditor extends Component<Signature> {

// flush the loader so that the preview (when card instance data is shown),
// or schema editor (when module code is shown) gets refreshed on save
return file.write(content, {
flushLoader: hasExecutableExtension(file.name),
saveType,
});
return file
.write(content, {
flushLoader: hasExecutableExtension(file.name),
saveType,
})
.catch((error) => {
if (error?.status === 413 && error?.title) {
this.args.onWriteError?.(error.title);
return;
}
let message =
error?.message ??
(error?.title ? `${error.title}: ${error.message ?? ''}` : undefined);
this.args.onWriteError?.(message ? message.trim() : String(error));
});
}

private safeJSONParse(content: string) {
Expand Down
8 changes: 8 additions & 0 deletions packages/host/app/components/operator-mode/code-submode.gts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export default class CodeSubmode extends Component<Signature> {
@tracked private loadFileError: string | null = null;
@tracked private userHasDismissedURLError = false;
@tracked private sourceFileIsSaving = false;
@tracked private writeError: string | undefined;
@tracked private isCreateModalOpen = false;
@tracked private itemToDelete: CardDef | URL | null | undefined;
@tracked private cardResource: ReturnType<getCard> | undefined;
Expand Down Expand Up @@ -373,6 +374,11 @@ export default class CodeSubmode extends Component<Signature> {
this.sourceFileIsSaving = status === 'started';
}

@action
private onWriteError(message: string | undefined) {
this.writeError = message;
}

@action
private onHorizontalLayoutChange(layout: number[]) {
if (layout.length > 2) {
Expand Down Expand Up @@ -731,13 +737,15 @@ export default class CodeSubmode extends Component<Signature> {
@saveSourceOnClose={{@saveSourceOnClose}}
@selectDeclaration={{this.selectDeclaration}}
@onFileSave={{this.onSourceFileSave}}
@onWriteError={{this.onWriteError}}
@onSetup={{this.setupCodeEditor}}
@isReadOnly={{this.isReadOnly}}
/>

<CodeSubmodeEditorIndicator
@isSaving={{this.isSaving}}
@isReadOnly={{this.isReadOnly}}
@errorMessage={{this.writeError}}
/>
{{else if this.isLoading}}
<LoadingIndicator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TemplateOnlyComponent } from '@ember/component/template-only';

import { LoadingIndicator } from '@cardstack/boxel-ui/components';

import { bool, or } from '@cardstack/boxel-ui/helpers';
import { CheckMark, IconPencilCrossedOut } from '@cardstack/boxel-ui/icons';

interface Signature {
Expand All @@ -12,6 +13,7 @@ interface Signature {
Args: {
isReadOnly: boolean;
isSaving: boolean;
errorMessage?: string;
};
}

Expand Down Expand Up @@ -50,6 +52,12 @@ const CodeSubmodeEditorIndicator: TemplateOnlyComponent<Signature> = <template>
background-color: #ffd73c;
}

.indicator-error {
--icon-color: #fff;
background-color: rgba(220, 53, 69, 0.9);
color: #fff;
}

.indicator.visible {
transform: translateX(0px);
transition-delay: 0s;
Expand Down Expand Up @@ -88,8 +96,17 @@ const CodeSubmodeEditorIndicator: TemplateOnlyComponent<Signature> = <template>
</span>
</div>
{{else}}
<div class='indicator indicator-saving {{if @isSaving "visible"}}'>
{{#if @isSaving}}
<div
class='indicator
{{if @errorMessage "indicator-error" "indicator-saving"}}
{{if (or @isSaving (bool @errorMessage)) "visible"}}'
data-test-save-error={{if @errorMessage 'true'}}
>
{{#if @errorMessage}}
<span class='indicator-msg'>
{{@errorMessage}}
</span>
{{else if @isSaving}}
<span class='indicator-msg'>
Now Saving
</span>
Expand Down
1 change: 1 addition & 0 deletions packages/host/app/config/environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ declare const config: {
publishedRealmBoxelSpaceDomain: string;
publishedRealmBoxelSiteDomain: string;
defaultSystemCardId: string;
cardSizeLimitBytes: number;
};
51 changes: 50 additions & 1 deletion packages/host/app/services/card-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import {
type RealmInfo,
type Loader,
} from '@cardstack/runtime-common';

import type { AtomicOperation } from '@cardstack/runtime-common/atomic-document';
import { createAtomicDocument } from '@cardstack/runtime-common/atomic-document';
import { validateWriteSize } from '@cardstack/runtime-common/write-size-validation';

import type {
BaseDef,
Expand All @@ -27,6 +27,7 @@ import type * as CardAPI from 'https://cardstack.com/base/card-api';

import LimitedSet from '../lib/limited-set';

import type EnvironmentService from './environment-service';
import type LoaderService from './loader-service';
import type MessageService from './message-service';
import type NetworkService from './network';
Expand Down Expand Up @@ -57,10 +58,13 @@ export default class CardService extends Service {
@service declare private loaderService: LoaderService;
@service declare private messageService: MessageService;
@service declare private network: NetworkService;
@service declare private environmentService: EnvironmentService;
@service declare private realm: Realm;
@service declare private reset: ResetService;

private subscriber: CardSaveSubscriber | undefined;
// This error will be used by check-correctness command to report size limit errors
private sizeLimitError = new Map<string, Error>();
// For tracking requests during the duration of this service. Used for being able to tell when to ignore an incremental indexing realm event.
// We want to ignore it when it is a result of our own request so that we don't reload the card and overwrite any unsaved changes made during auto save request and realm event.
declare private loaderToCardAPILoadingCache: WeakMap<
Expand Down Expand Up @@ -103,6 +107,10 @@ export default class CardService extends Service {
this.subscriber = undefined;
}

getSizeLimitError(url: string): Error | undefined {
return this.sizeLimitError.get(url);
}

async fetchJSON(
url: string | URL,
args?: CardServiceRequestInit,
Expand Down Expand Up @@ -140,6 +148,20 @@ export default class CardService extends Service {
requestInit.method = 'POST';
requestHeaders.set('X-HTTP-Method-Override', 'QUERY');
}
let urlString = url instanceof URL ? url.href : url;
let method = requestInit.method?.toUpperCase?.();
if (
!isReadOperation &&
(method === 'POST' || method === 'PATCH') &&
requestInit.body
) {
let jsonString =
typeof requestInit.body === 'string'
? requestInit.body
: JSON.stringify(requestInit.body, null, 2);
this.validateSizeLimit(urlString, jsonString, 'card');
}

let response = await this.network.authedFetch(url, requestInit);
if (!response.ok) {
let responseText = await response.text();
Expand Down Expand Up @@ -191,6 +213,7 @@ export default class CardService extends Service {
options?: SaveSourceOptions,
) {
try {
this.validateSizeLimit(url.href, content, 'file');
let clientRequestId = options?.clientRequestId ?? `${type}:${uuidv4()}`;
this.clientRequestIds.add(clientRequestId);

Expand Down Expand Up @@ -294,6 +317,17 @@ export default class CardService extends Service {
}

async executeAtomicOperations(operations: AtomicOperation[], realmURL: URL) {
for (let operation of operations) {
if (operation.data?.type === 'source') {
let content = operation.data.attributes?.content;
if (typeof content === 'string') {
this.validateSizeLimit(operation.href, content, 'file');
}
} else if (operation.data?.type === 'card') {
let jsonString = JSON.stringify(operation.data, null, 2);
this.validateSizeLimit(operation.href, jsonString, 'card');
}
}
let doc = createAtomicDocument(operations);
let response = await this.network.authedFetch(`${realmURL.href}_atomic`, {
method: 'POST',
Expand All @@ -304,6 +338,21 @@ export default class CardService extends Service {
});
return response.json();
}

private validateSizeLimit(
url: string,
content: string,
type: 'card' | 'file',
) {
let maxSizeBytes = this.environmentService.cardSizeLimitBytes;
try {
this.sizeLimitError.delete(url);
validateWriteSize(content, maxSizeBytes, type);
} catch (e: any) {
this.sizeLimitError.set(url, e);
throw e;
}
}
}

declare module '@ember/service' {
Expand Down
4 changes: 3 additions & 1 deletion packages/host/app/services/environment-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import Service from '@ember/service';

import config from '@cardstack/host/config/environment';

const { autoSaveDelayMs } = config;
const { autoSaveDelayMs, cardSizeLimitBytes } = config;

// we use this service to help instrument environment settings in tests
export default class EnvironmentService extends Service {
autoSaveDelayMs: number;
cardSizeLimitBytes: number;

constructor(owner: Owner) {
super(owner);
this.autoSaveDelayMs = autoSaveDelayMs;
this.cardSizeLimitBytes = cardSizeLimitBytes;
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/host/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const fs = require('fs');
const path = require('path');
const DEFAULT_CARD_RENDER_TIMEOUT_MS = 30_000;
const DEFAULT_CARD_SIZE_LIMIT_BYTES = 64 * 1024;

let sqlSchema = fs.readFileSync(getLatestSchemaFile(), 'utf8');

Expand Down Expand Up @@ -40,6 +41,9 @@ module.exports = function (environment) {
cardRenderTimeout: Number(
process.env.RENDER_TIMEOUT_MS ?? DEFAULT_CARD_RENDER_TIMEOUT_MS,
),
cardSizeLimitBytes: Number(
process.env.CARD_SIZE_LIMIT_BYTES ?? DEFAULT_CARD_SIZE_LIMIT_BYTES,
),
iconsURL: process.env.ICONS_URL || 'https://boxel-icons.boxel.ai',
publishedRealmBoxelSpaceDomain:
process.env.PUBLISHED_REALM_BOXEL_SPACE_DOMAIN || 'localhost:4201',
Expand Down
34 changes: 34 additions & 0 deletions packages/host/tests/acceptance/code-submode-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1965,6 +1965,40 @@ module('Acceptance | code submode tests', function (_hooks) {
);
});

test('code editor shows size limit error when json save exceeds limit', async function (assert) {
let environmentService = getService('environment-service') as any;
let originalMaxSize = environmentService.cardSizeLimitBytes;

try {
await visitOperatorMode({
submode: 'code',
codePath: `${testRealmURL}person-entry.json`,
});

await waitFor('[data-test-editor]');

let content = getMonacoContent();
let encoder = new TextEncoder();
let currentSize = encoder.encode(content).length;
environmentService.cardSizeLimitBytes = currentSize + 50;

let doc = JSON.parse(content);
doc.data.attributes = {
...(doc.data.attributes ?? {}),
title: 'x'.repeat(currentSize + 200),
};
setMonacoContent(JSON.stringify(doc, null, 2));
await waitFor('[data-test-save-error]');
assert
.dom('[data-test-save-error]')
.includesText(
`exceeds maximum allowed size (${environmentService.cardSizeLimitBytes} bytes)`,
);
} finally {
environmentService.cardSizeLimitBytes = originalMaxSize;
}
});

test('card preview live updates when index changes', async function (assert) {
await visitOperatorMode({
stacks: [
Expand Down
Loading