Skip to content
Open
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);
Comment on lines +478 to +480
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

This code checks for error.title when status is 413, but the actual error message is displayed. However, when the error doesn't have a title (for non-413 errors), the logic falls through to constructing a message from error.title and error.message. If error.title exists but error.message doesn't, this will result in "title: " with no actual message. Consider checking if error.message exists before attempting to construct this compound message.

Suggested change
let message =
error?.message ??
(error?.title ? `${error.title}: ${error.message ?? ''}` : undefined);
let message = error?.message ?? error?.title;

Copilot uses AI. Check for mistakes.
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;
cardSizeLimit: 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.cardSizeLimit;
try {
this.sizeLimitError.delete(url);
validateWriteSize(content, maxSizeBytes, type);
} catch (e: any) {
this.sizeLimitError.set(url, e);
throw new Error(e);
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The error is wrapped in a new Error() with the original error object passed as a string, which loses the error's properties. This should be throw e; to preserve the error object and its properties, or properly construct the Error with the message: throw new Error(e.message);

Suggested change
throw new Error(e);
throw e;

Copilot uses AI. Check for mistakes.
}
}
}

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, cardSizeLimit } = config;

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

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

Expand Down
1 change: 1 addition & 0 deletions packages/host/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ module.exports = function (environment) {
cardRenderTimeout: Number(
process.env.RENDER_TIMEOUT_MS ?? DEFAULT_CARD_RENDER_TIMEOUT_MS,
),
cardSizeLimit: Number(process.env.CARD_SIZE_LIMIT ?? 64 * 1024),
Copy link
Contributor

Choose a reason for hiding this comment

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

Adjust name or add comment indicating units (bytes, I assume?)

Copy link
Contributor

Choose a reason for hiding this comment

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

why do we hard code 64 *1024 twice? once here and once in runtime-common. let's just have a single place for this constant

Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The configuration variable cardSizeLimit is used to validate both card and file sizes, but the name only refers to cards. This is consistent with the naming throughout the codebase, but it may be misleading. Consider renaming to something more generic like writeSizeLimit or maxPayloadSize in a future refactor to better reflect that it applies to both cards and files.

Copilot uses AI. Check for mistakes.
iconsURL: process.env.ICONS_URL || 'https://boxel-icons.boxel.ai',
publishedRealmBoxelSpaceDomain:
process.env.PUBLISHED_REALM_BOXEL_SPACE_DOMAIN || 'localhost:4201',
Expand Down
32 changes: 32 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,38 @@ 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.cardSizeLimit;

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.cardSizeLimit = 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 (270 bytes)');
Comment on lines +1983 to +1994
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The test logic may be flawed. The test sets cardSizeLimit = currentSize + 50, then creates a title with 'x'.repeat(currentSize + 200). However, the assertion checks for "exceeds maximum allowed size (270 bytes)". The number 270 doesn't match currentSize + 50. The test should either use a fixed size limit or assert against the actual calculated limit. The hardcoded "270 bytes" makes this test brittle and unclear.

Copilot uses AI. Check for mistakes.
} finally {
environmentService.cardSizeLimit = originalMaxSize;
}
});

test('card preview live updates when index changes', async function (assert) {
await visitOperatorMode({
stacks: [
Expand Down
45 changes: 45 additions & 0 deletions packages/host/tests/acceptance/interact-submode-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
typeIn,
triggerKeyEvent,
settled,
waitUntil,
} from '@ember/test-helpers';

import { triggerEvent } from '@ember/test-helpers';
Expand Down Expand Up @@ -1199,4 +1200,48 @@ module('Acceptance | interact submode tests', function (hooks) {
await assertFocusPreserved(withLinksSelector, `With Pet${typedText}`);
});
});

module('size limit errors', function () {
test('edit view shows size limit error when save exceeds limit', async function (assert) {
let environmentService = getService('environment-service') as any;
let originalMaxSize = environmentService.cardSizeLimit;
environmentService.cardSizeLimit = 1000;

try {
await visitOperatorMode({
stacks: [
[
{
id: `${testRealmURL}Pet/mango`,
format: 'edit',
},
],
],
});

await fillIn(
`[data-test-stack-card="${testRealmURL}Pet/mango"] [data-test-field="name"] input`,
'x'.repeat(5000),
);

await waitUntil(() =>
Boolean(
!find(
`[data-test-stack-card="${testRealmURL}Pet/mango"] [data-test-auto-save-indicator]`,
)?.textContent?.includes('Saving'),
),
);

assert
.dom(
`[data-test-stack-card="${testRealmURL}Pet/mango"] [data-test-auto-save-indicator]`,
)
.includesText(
`exceeds maximum allowed size (${environmentService.cardSizeLimit} bytes)`,
);
} finally {
environmentService.cardSizeLimit = originalMaxSize;
}
});
});
});
Loading
Loading