Skip to content
Draft
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
58 changes: 58 additions & 0 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,13 @@ export interface Field<
): Promise<any>;
emptyValue(instance: BaseDef): any;
validate(instance: BaseDef, value: any): void;
// Deserialization-only guard, applied before `validate` when a document is
// being loaded. A link field's target lives in another document — its index
// row can be wrong or stale independently of this card — so a target that
// does not satisfy the field's declared type is dropped (with a warning)
// rather than making this whole card unloadable. Direct assignment does not
// pass through here: `validate` still throws for a user-set mismatch.
sanitizeDeserialized?(instance: BaseDef, value: any): any;
component(model: Box<BaseDef>): BoxComponent;
getter(instance: BaseDef): BaseInstanceType<CardT> | undefined;
queryableValue(value: any, stack: BaseDef[]): SearchT;
Expand Down Expand Up @@ -1592,6 +1599,26 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
return value;
}

sanitizeDeserialized(_instance: CardDef, value: any) {
if (!value || isNonPresentLink(value) || primitive in this.card) {
return value;
}
if (
instanceOf(value, this.card) &&
!(isFileDef(this.card) && !value.id)
) {
return value;
}
console.warn(
`dropping deserialized linksTo '${this.name}' target: ${
value.constructor?.name
} does not satisfy ${this.card.name}${
isFileDef(this.card) && !value.id ? ' (missing id)' : ''
}`,
);
return null;
}

captureQueryFieldSeedData(
instance: BaseDef,
value: CardDef,
Expand Down Expand Up @@ -2256,6 +2283,34 @@ class LinksToMany<FieldT extends LinkableDefConstructor> implements Field<
);
}

sanitizeDeserialized(_instance: BaseDef, values: any[] | null) {
if (values == null || !Array.isArray(values) || primitive in this.card) {
return values;
}
let expectedCard = this.declaredCard;
let conforms = (value: any) =>
isNonPresentLink(value) ||
value == null ||
(instanceOf(value, expectedCard) &&
!(isFileDef(expectedCard) && !value.id));
let raw = rawArrayValues(values);
if (raw.every(conforms)) {
return values;
}
for (let value of raw) {
if (!conforms(value)) {
console.warn(
`dropping deserialized linksToMany '${this.name}' entry: ${
value.constructor?.name
} does not satisfy ${expectedCard.name}${
isFileDef(expectedCard) && !value.id ? ' (missing id)' : ''
}`,
);
}
}
return raw.filter(conforms);
}

captureQueryFieldSeedData(
instance: BaseDef,
value: CardDef[],
Expand Down Expand Up @@ -4659,6 +4714,9 @@ async function _updateFromSerialized<T extends BaseDefConstructor>({
);
}
propagateRealmContext(value, realmURLString);
if (field.sanitizeDeserialized) {
value = field.sanitizeDeserialized(instance, value);
}
field.validate(instance, value);

// Before updating field's value, we also have to make sure
Expand Down
45 changes: 45 additions & 0 deletions packages/host/app/services/ai-assistant-panel-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { action } from '@ember/object';
import type Owner from '@ember/owner';
import { service } from '@ember/service';
import Service from '@ember/service';
import { isTesting } from '@embroider/macros';
import { tracked } from '@glimmer/tracking';

import { allSettled, restartableTask } from 'ember-concurrency';
Expand Down Expand Up @@ -261,6 +262,50 @@ export default class AiAssistantPanelService extends Service {
if (hidePastSessionsList) {
this.hidePastSessions();
}
void this.ensureDefaultSkillsApplied(roomId);
}

// Rooms this session has already checked for a missing default-skill state
// (or repaired). Rechecking on every entry would re-run the skills tool for
// rooms the user deliberately keeps skill-less mid-session.
private skillBackfillCheckedRooms = new Set<string>();

// A room created while the default-skill lookup was failing (the SystemCard
// unavailable, the skills realm unreachable) carries an empty skills state
// permanently: room state is persisted, the panel reuses the unused room as
// the "new session", and nothing re-applies the defaults. On entering a
// room that has no messages and has never had a skill attached, apply the
// defaults now. A room where the user disabled every skill is untouched —
// disabling moves a skill to the disabled list rather than removing it.
private async ensureDefaultSkillsApplied(roomId: string) {
if (isTesting()) {
// Tests assert on room skill state they set up themselves; a background
// backfill would mutate it nondeterministically.
return;
}
if (this.skillBackfillCheckedRooms.has(roomId)) {
return;
}
if (this.doCreateRoom.isRunning) {
// Room creation applies (or defers) the defaults itself.
return;
}
let resource = this.matrixService.roomResources.get(roomId);
let room = resource?.matrixRoom;
if (!room) {
// Room state has not synced yet; a later entry re-checks.
return;
}
let { enabledSkillCards = [], disabledSkillCards = [] } =
room.skillsConfig ?? {};
this.skillBackfillCheckedRooms.add(roomId);
if (enabledSkillCards.length || disabledSkillCards.length) {
return;
}
if (resource!.messages.length > 0) {
return;
}
await this.applyDefaultSkillsToRoom(roomId);
}

@action
Expand Down
42 changes: 42 additions & 0 deletions packages/host/app/services/matrix-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ const STATE_EVENTS_OF_INTEREST = ['m.room.create', 'm.room.name'];
// so a persistently-down server doesn't spin forever.
const UNREACHABLE_RETRY_INTERVAL_MS = 10_000;
const MAX_UNREACHABLE_RETRY_ATTEMPTS = 6;
// Pause before each retry of a failed SystemCard load. Without a retry, one
// transient failure at boot locks the whole session into the fallback model
// list and the hardcoded default skills.
const SYSTEM_CARD_RETRY_DELAYS_MS = [5_000, 15_000, 60_000, 5 * 60_000];

const realmEventsLogger = logger('realm:events');

Expand Down Expand Up @@ -3227,6 +3231,9 @@ export default class MatrixService extends Service {
envDefaultFailed ||
(userChoiceFailed && !envDefaultId) ||
(this._systemCardWasLost && !loadedCard);
if (this._systemCardLoadFailed) {
this.scheduleSystemCardRetry();
}

if (loadedCard?.id !== this._systemCard?.id) {
this._systemCardInvalidationUnsub?.();
Expand All @@ -3246,6 +3253,41 @@ export default class MatrixService extends Service {
}
}

private scheduleSystemCardRetry() {
if (isTesting()) {
// Tests drive recovery via `setSystemCard` directly so the assertions
// are deterministic; skip the background timer loop, which would
// otherwise keep firing while a stubbed card stays unloadable.
return;
}
if (!this._systemCardLoadFailed || this.retrySystemCardLoadTask.isRunning) {
return;
}
this.retrySystemCardLoadTask.perform();
}

// Bounded like retryUnreachableRealmServersTask: after the schedule is
// exhausted the session stays on the fallback SystemCard until something
// else re-enters setSystemCard (an account-data change, a reload).
private retrySystemCardLoadTask = restartableTask(async () => {
for (
let attempt = 0;
this._systemCardLoadFailed &&
attempt < SYSTEM_CARD_RETRY_DELAYS_MS.length;
attempt++
) {
await rawTimeout(SYSTEM_CARD_RETRY_DELAYS_MS[attempt]);
if (this.isDestroying || this.isDestroyed) {
return;
}
try {
await this.setSystemCard(this._userChoiceId);
} catch (err) {
console.error('Failed to retry SystemCard load', err);
}
}
});

// Fires when the active SystemCard is deleted in the same session (either
// via the in-tab UI or via a matrix-auth-room invalidation originating
// elsewhere). Re-evaluate the chain so the fallback banner surfaces or the
Expand Down
112 changes: 106 additions & 6 deletions packages/host/app/utils/file-def-attributes-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,41 @@ export type FileDefExtractResult = {
frontmatterDiagnostics?: Partial<Diagnostics>;
};

// A failure to obtain the file's bytes: the fetch rejected, returned non-ok,
// or the body stream errored mid-read. The extractor's class-chain fallback
// exists for parse failures — a subclass that cannot make sense of the
// content hands off to its parent. A byte-acquisition failure is different in
// kind: every class faces the same missing bytes, and the base FileDef
// "succeeds" without reading them, which would index the file as a bare
// FileDef — permanently dropping its subtype fields (a markdown skill loses
// `kind`, an image loses its dimensions) with nothing to retry the row. The
// stream plumbing tags these errors so `extract()` can abort the chain and
// return `status: 'error'`, which the indexer persists as a retryable error
// row instead.
export class FileBytesUnavailableError extends Error {
isFileBytesUnavailable = true;
cause: unknown;
constructor(fileURL: string, cause: unknown) {
super(
`could not obtain file bytes for ${fileURL}: ${
(cause as Error)?.message ?? String(cause)
}`,
);
this.name = 'FileBytesUnavailableError';
this.cause = cause;
}
}

export function isFileBytesUnavailableError(
error: unknown,
): error is FileBytesUnavailableError {
return (
typeof error === 'object' &&
error != null &&
(error as FileBytesUnavailableError).isFileBytesUnavailable === true
);
}

export class FileDefAttributesExtractor {
#loaderService: LoaderService;
#network: NetworkService;
Expand Down Expand Up @@ -160,6 +195,7 @@ export class FileDefAttributesExtractor {
let deps = [this.#fileDefCodeRef.module];
let error: RenderError | undefined;
let mismatch = false;
let bytesUnavailable: FileBytesUnavailableError | undefined;

let recordError = (err: unknown) => {
if (!error) {
Expand Down Expand Up @@ -194,7 +230,14 @@ export class FileDefAttributesExtractor {
`[file-extract] ${(klass as any).displayName ?? (klass as any).name ?? 'unknown'}.extractAttributes failed for ${this.#fileURL}:`,
err,
);
recordError(err);
// A byte-acquisition failure aborts the whole chain (see
// FileBytesUnavailableError) — falling back to a parent class would
// misclassify the file rather than repair anything.
if (isFileBytesUnavailableError(err)) {
bytesUnavailable = err;
} else {
recordError(err);
}
return undefined;
}
};
Expand Down Expand Up @@ -233,6 +276,9 @@ export class FileDefAttributesExtractor {
? 'Base FileDef module did not export extractAttributes'
: 'FileDef module did not export extractAttributes';
let searchDoc = await tryExtract(klass, missingMessage);
if (bytesUnavailable) {
break;
}
if (searchDoc) {
let typeCodeRefs = getTypes(klass);
let types = typeCodeRefs.map((type) =>
Expand Down Expand Up @@ -307,6 +353,19 @@ export class FileDefAttributesExtractor {
}
}

if (bytesUnavailable) {
return {
status: 'error',
searchDoc: null,
deps,
error: this.#buildError(
this.#fileURL,
bytesUnavailable.cause ?? bytesUnavailable,
),
...(mismatch ? { mismatch: true } : {}),
};
}

return {
status: 'error',
searchDoc: null,
Expand Down Expand Up @@ -334,12 +393,24 @@ export class FileDefAttributesExtractor {
// runtime-common/stream.ts does this (it cancels in a `finally`), and is what
// the header-only extractors (the image defs) already use; partial readers
// should go through it rather than draining the stream by hand.
//
// Failures anywhere in this plumbing — the fetch itself, buffering, or a
// mid-read error on the live body — are surfaced as
// FileBytesUnavailableError so the extract aborts instead of falling back
// down the class chain.
#getStreamForAttempt = async () => {
if (!this.#primaryUsed) {
this.#primaryUsed = true;
return this.#getPrimaryStream();
try {
if (!this.#primaryUsed) {
this.#primaryUsed = true;
return await this.#getPrimaryStream();
}
return await this.#getRetryStream();
} catch (err) {
if (isFileBytesUnavailableError(err)) {
throw err;
}
throw new FileBytesUnavailableError(this.#fileURL, err);
}
return this.#getRetryStream();
};

async #getPrimaryStream(): Promise<ReadableStream<Uint8Array> | Uint8Array> {
Expand All @@ -351,11 +422,40 @@ export class FileDefAttributesExtractor {
// back to a buffered read only when the body isn't a stream (real fetches
// always expose one; this keeps non-streaming environments working).
if (response.body) {
return response.body;
return this.#tagStreamErrors(response.body);
}
return new Uint8Array(await response.arrayBuffer());
}

// A pass-through over the live body whose read failures reject with
// FileBytesUnavailableError, so a connection reset mid-stream is
// classified the same way as a failed fetch.
#tagStreamErrors(
stream: ReadableStream<Uint8Array>,
): ReadableStream<Uint8Array> {
let reader = stream.getReader();
let fileURL = this.#fileURL;
return new ReadableStream<Uint8Array>({
async pull(controller) {
let result: ReadableStreamReadResult<Uint8Array>;
try {
result = await reader.read();
} catch (err) {
controller.error(new FileBytesUnavailableError(fileURL, err));
return;
}
if (result.done) {
controller.close();
} else {
controller.enqueue(result.value);
}
},
cancel(reason) {
return reader.cancel(reason);
},
});
}

async #getRetryStream(): Promise<Uint8Array> {
if (this.#fileBytes) {
return this.#fileBytes;
Expand Down
Loading
Loading