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
5 changes: 5 additions & 0 deletions backend/__tests__/unit/services/externalFeedService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ describe('externalFeedService', () => {
$set: expect.objectContaining({
status: 'error',
errorMessage: 'invalid_request',
// Named, not omitted (wren 73858): a `$set` that leaves a key out
// inherits the row's stored value, so silence here would keep a stale
// `true` from an earlier human-authored reason and print this
// provider text in the user's connector list.
errorMessageUserFacing: false,
}),
}),
);
Expand Down
47 changes: 45 additions & 2 deletions backend/__tests__/unit/services/installableCatalogService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,46 @@ const githubTool = () => ({
describe('installable catalog service', () => {
beforeEach(() => jest.clearAllMocks());

it('carries the flag beside the message, so the page can tell a reason from an exception', async () => {
mockInstallables([
{ installableId: 'telegram', name: 'Telegram', description: 'Telegram description' },
{ installableId: 'slack', name: 'Slack', description: 'Slack description' },
]);
InstallableInstallation.find.mockReturnValue(lean([
{
_id: installationId,
installableId: 'telegram',
status: 'error',
errorMessage: "This connector's channel is gone. Retry to rebuild it.",
// The reconciler's own constant: a writer declared it readable.
errorMessageUserFacing: true,
targetId: userId,
installedBy: userId,
components: [],
},
{
_id: '6a8f6dc7a1dccf2e02f31099',
installableId: 'slack',
status: 'error',
// `markProjectionFailure`'s raw exception: the same field, a different
// kind of string, and the flag is what separates them.
errorMessage: 'connect ECONNREFUSED 10.4.4.7:443',
targetId: userId,
installedBy: userId,
components: [],
},
]));
Integration.find.mockReturnValue(lean([]));

const catalog = await catalogFor(userId);
const byId = Object.fromEntries(catalog.installables.map((entry) => [entry.installableId, entry.installation]));

expect(byId.telegram.errorMessageUserFacing).toBe(true);
expect(byId.slack.errorMessageUserFacing).toBe(false);
// The message still travels — the page, not the mapper, decides what to show.
expect(byId.slack.errorMessage).toBe('connect ECONNREFUSED 10.4.4.7:443');
});

it('returns provider readiness and the caller parent without leaking private fields', async () => {
mockInstallables([
{ installableId: 'telegram', name: 'Telegram', description: 'Telegram description' },
Expand All @@ -49,7 +89,7 @@ describe('installable catalog service', () => {
_id: installationId,
installableId: 'telegram',
status: 'error',
errorMessage: 'projection missing',
errorMessage: "This connector's channel is gone. Retry to rebuild it.",
targetId: 'another-user',
installedBy: 'another-user',
claimId: 'private-claim',
Expand Down Expand Up @@ -89,7 +129,10 @@ describe('installable catalog service', () => {
available: true,
installation: {
status: 'error',
errorMessage: 'projection missing',
errorMessage: "This connector's channel is gone. Retry to rebuild it.",
// The mapper hands over the provenance with the message, so the page
// never has to guess from the shape of the string (TASK-131).
errorMessageUserFacing: false,
boundPodId: podId,
claimedAt: '2026-09-05T00:00:00.000Z',
updatedAt: '2026-09-05T00:01:00.000Z',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ describe('installable connector projection', () => {
const failed = await InstallableInstallation.findOne({ installableId: 'telegram' });
const inactive = await Integration.findOne({ installationId: String(failed._id) });
expect(failed.status).toBe('error');
// `markProjectionFailure` stores the projector's own exception text verbatim
// — stack-adjacent detail like `connect ECONNREFUSED <addr>:443` — so it is
// the writer that must say `false`. The catalog row renders this field only
// when the flag is true, and the row belongs to the installer, not to us
// (TASK-131, vera 73848).
expect(failed.errorMessageUserFacing).toBe(false);
expect(inactive.isActive).toBe(false);
expect(inactive.config.connectCode).toBeUndefined();

Expand Down Expand Up @@ -595,8 +601,20 @@ describe('installable connector projection', () => {

const reconciled = await sweep(new Date());
expect(reconciled.errored).toBe(2);
expect((await InstallableInstallation.findById(installing._id)).status).toBe('error');
expect((await InstallableInstallation.findById(activating._id)).status).toBe('error');
const expiring = await InstallableInstallation.findById(installing._id);
const expired = await InstallableInstallation.findById(activating._id);
expect(expiring.status).toBe('error');
expect(expired.status).toBe('error');
// Both reasons are our own constants, written for the person reading the
// Connectors page, so the writer states that beside the message. Without it
// the catalog row falls back to the generic sentence and the operator loses
// the only sentence that says what to do (TASK-131).
expect(expiring.errorMessageUserFacing).toBe(true);
expect(expired.errorMessageUserFacing).toBe(true);
// The copy itself is pinned here: these two strings are the whole set of
// reasons the catalogue will ever print verbatim, so a writer that changes
// one has to change this line too (wren 73914, vera 73915).
expect(expiring.errorMessage).toBe('Setup was interrupted before it finished. Try again.');
});

it('makes a missing active projection a retriable parent error', async () => {
Expand All @@ -608,7 +626,10 @@ describe('installable connector projection', () => {
const parent = await InstallableInstallation.findById(installed.installation._id);
expect(reconciled.staleComponents).toBe(1);
expect(parent.status).toBe('error');
expect(parent.errorMessage).toBe('projection missing');
expect(parent.errorMessage).toBe("This connector's channel is gone. Retry to rebuild it.");
// The reconciler's own constant: a message written for a person, so it
// carries the flag the page requires before rendering (TASK-131).
expect(parent.errorMessageUserFacing).toBe(true);
expect(parent.components.every((component) => component.status === 'stale')).toBe(true);

const retried = await install({ installableId: 'telegram', installedBy: userId, podId });
Expand Down
9 changes: 9 additions & 0 deletions backend/models/InstallableInstallation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ export interface IInstallableInstallation extends Document {
claimId?: string;
claimedAt?: Date;
errorMessage?: string;
/**
* Whether `errorMessage` was written for a person. Two kinds of string land
* in that field — the reconciler's human-authored constants, and
* `markProjectionFailure`'s raw exception message — and the Connectors page
* renders them through one branch, so only a flagged message is printed
* (vera 73848). Set together with the message by the builders below.
*/
errorMessageUserFacing?: boolean;
staleSince?: Date;
/** Canonical pause state; Integration.config.adminPause is its projection. */
adminPause?: IInstallationAdminPause;
Expand Down Expand Up @@ -230,6 +238,7 @@ const InstallableInstallationSchema = new Schema<IInstallableInstallation>(
claimId: { type: String },
claimedAt: { type: Date },
errorMessage: { type: String },
errorMessageUserFacing: { type: Boolean, default: false },
staleSince: { type: Date },
adminPause: {
reason: { type: String },
Expand Down
5 changes: 5 additions & 0 deletions backend/services/connectorDeliveryFailureService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ interface FlipInput {
* its only writer, and the page renders the message only when it is set; a
* writer that does not set it falls back to the generic sentence instead of
* printing our stack text in a connector row (vera 73848).
*
* Stated, not defaulted: a `$set` that omits this key leaves the row's stored
* value in place, so omitting the flag is only safe on a row that has never
* carried one. `externalFeedService` names `errorMessageUserFacing: false` for
* exactly that reason — absence would be a claim it could not make.
*/
const userFacingError = (reason: string) => ({
status: 'error',
Expand Down
12 changes: 8 additions & 4 deletions backend/services/externalFeedService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,11 +435,15 @@ async function syncExternalFeeds(): Promise<FeedSyncResult[]> {
$set: {
status: 'error',
// Diagnostic, not copy. This is a provider's error text or a raw
// exception message, so `errorMessageUserFacing` stays unset and
// the Connectors page shows its generic sentence for these rows —
// the detail is kept here for whoever debugs the feed instead of
// being printed in someone's connector list (vera 73848).
// exception message, so the detail is kept here for whoever debugs
// the feed rather than printed in someone's connector list (vera
// 73848). The flag is stated instead of left out because a `$set`
// that omits a key inherits the row's stored value: omitting it is
// only safe on a row that has never carried one, and naming it
// `false` is what makes the page's generic sentence this row's
// outcome no matter what it carried before.
errorMessage: detail,
errorMessageUserFacing: false,
lastSync: new Date(),
},
});
Expand Down
10 changes: 9 additions & 1 deletion backend/services/installable/installableCatalogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,15 @@ const publicInstallation = (installation: any): unknown => {
})) : [];
return {
status: installation.status,
...(installation.errorMessage ? { errorMessage: installation.errorMessage } : {}),
// The message travels with its provenance. The page prints it only when this
// is true, so a raw exception from `markProjectionFailure` reaches the
// client and is deliberately not rendered (TASK-131).
...(installation.errorMessage
? {
errorMessage: installation.errorMessage,
errorMessageUserFacing: installation.errorMessageUserFacing === true,
}
: {}),
...(installation.boundPodId ? { boundPodId: String(installation.boundPodId) } : {}),
...(installation.claimedAt ? { claimedAt: installation.claimedAt } : {}),
...(installation.updatedAt ? { updatedAt: installation.updatedAt } : {}),
Expand Down
33 changes: 31 additions & 2 deletions backend/services/installable/installableInstallationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,32 @@ const claimInstallation = async (
}
};

/**
* The two ways `InstallableInstallation.errorMessage` gets a value, each with
* its provenance attached.
*
* `errorMessage` alone cannot say the reason is fit to read. Three writers give
* it a value: `markProjectionFailure` (a raw exception —
* `connect ECONNREFUSED <addr>:443`), and the reconciler's two human-authored
* constants. The Connectors page renders every failed installation through one
* branch, so "the field has a value" is not the same fact as "the value was
* written for a person" (vera 73848). `errorMessageUserFacing` is that fact, and
* a message and its flag are written together here so that no writer can set
* half of the pair — the catalog row is a stranger's row, and printing our stack
* text in it is disclosure, not diagnosis.
*/
export const userFacingInstallationError = (reason: string) => ({
status: 'error',
errorMessage: reason,
errorMessageUserFacing: true,
});

export const diagnosticInstallationError = (error: Error) => ({
status: 'error',
errorMessage: error.message,
errorMessageUserFacing: false,
});

const throwIfLockLost = (installation: IInstallableInstallation | null): IInstallableInstallation => {
if (!installation) throw new InstallLockLostError();
return installation;
Expand All @@ -336,8 +362,7 @@ const markProjectionFailure = async (
{ _id: installation._id, status: 'installing', claimId },
{
$set: {
status: 'error',
errorMessage: error.message,
...diagnosticInstallationError(error),
components,
},
},
Expand Down Expand Up @@ -688,4 +713,8 @@ module.exports = {
InstallableNotFoundError,
install,
uninstall,
// Named here as well as exported above: this file's CommonJS tail replaces the
// ES exports, so an `export const` on its own is invisible to every require().
userFacingInstallationError,
diagnosticInstallationError,
};
13 changes: 9 additions & 4 deletions backend/services/installable/installableReconciler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IInstallableInstallation } from '../../models/InstallableInstallation';
import { INSTALL_LOCK_TTL_MS } from './installableInstallationService';
import { INSTALL_LOCK_TTL_MS, userFacingInstallationError } from './installableInstallationService';

// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const InstallableInstallation = require('../../models/InstallableInstallation');
Expand Down Expand Up @@ -58,7 +58,10 @@ const sweepStaleLocks = async (now: Date): Promise<{ completed: number; errored:

const result = await InstallableInstallation.updateOne(
{ _id: installation._id, status: installation.status, claimId },
{ $set: { status: 'error', errorMessage: 'install lock expired' } },
// The copy is the outcome the operator reads in the Connectors list, so
// it names the event and the next step and no internal one: a lock is our
// word, not theirs (wren 73914, vera 73915).
{ $set: userFacingInstallationError('Setup was interrupted before it finished. Try again.') },
);
errored += result.modifiedCount || 0;
}
Expand Down Expand Up @@ -87,8 +90,10 @@ const sweepActiveInstallations = async (): Promise<{ staleComponents: number; cl
{ _id: installation._id, status: 'active' },
{
$set: {
status: 'error',
errorMessage: 'projection missing',
// Distinct from the page's generic 'Setup didn't finish.' on purpose: the
// connector existed and its channel record vanished, which is a different
// event, and the evidence pair has to read as two outcomes (vera 73915).
...userFacingInstallationError("This connector's channel is gone. Retry to rebuild it."),
'components.$[].status': 'stale',
},
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions docs/design/evidence/task-131-catalog-error-line-1200-after.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
EN
LO
Connectors

Each channel gets one agent from a pod. It answers there in its own name; the rest of the team stays behind it.

Slack
no pod · since 13m
This connector's channel is gone. Retry to rebuild it.
retry, or remove it
Retry
Telegram
no pod · since 13m
Setup didn’t finish.
retry, or remove it
Retry
Discord · WhatsApp
not yet
Not yet. Tell us which channel you need and we build it next.
Ask

next step

This connector's channel is gone. Retry to rebuild it.

Retry
Remove
Tools
1 granted
All
Granted
Not yet
GitHub
kai routing check · granted 6d
granted to Kai routing check
localdev may use it
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions docs/design/evidence/task-131-catalog-error-line-1200-before.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
EN
LO
Connectors

Each channel gets one agent from a pod. It answers there in its own name; the rest of the team stays behind it.

Slack
no pod · since 13m
This connector's channel is gone. Retry to rebuild it.
retry, or remove it
Retry
Telegram
no pod · since 13m
connect ECONNREFUSED 10.4.4.7:443
retry, or remove it
Retry
Discord · WhatsApp
not yet
Not yet. Tell us which channel you need and we build it next.
Ask

next step

This connector's channel is gone. Retry to rebuild it.

Retry
Remove
Tools
1 granted
All
Granted
Not yet
GitHub
kai routing check · granted 6d
granted to Kai routing check
localdev may use it
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions docs/design/evidence/task-131-catalog-error-line-390-after.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Connectors

Each channel gets one agent from a pod. It answers there in its own name; the rest of the team stays behind it.

Slack
no pod · since 14m
This connector's channel is gone. Retry to rebuild it.
retry, or remove it
Retry
Telegram
no pod · since 14m
Setup didn’t finish.
retry, or remove it
Retry
Discord · WhatsApp
not yet
Not yet. Tell us which channel you need and we build it next.
Ask

next step

This connector's channel is gone. Retry to rebuild it.

Retry
Remove
Tools
1 granted
All
Granted
Not yet
GitHub
kai routing check · granted 6d · every write asks first
granted to Kai routing check
localdev may use it
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading