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
36 changes: 36 additions & 0 deletions .changeset/10039-publish-drafts-advisories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@object-ui/app-shell': patch
---

The AI build bar, the Studio workbench and the chat transcript's draft cards report the runtime authoring gate's per-draft advisories (objectui#10039)

`POST /packages/:id/publish-drafts` has answered `advisories` on each
`published[]` element since objectstack#9343 landed, and objectui#6965 built the
seam that reports them — `MetadataClient.publishPackageDrafts`, which emits one
advisory event per advised element into the sink, renderer and wording the save
and single-item publish doors use. Three app-shell call sites were still firing
that route with a bare `fetch`, so on those surfaces the findings were parsed by
nobody: `console/ai/PendingDraftsBar`, `views/studio-design/StudioDesignSurface`
and the chat draft card's publish handler in `console/ai/AiChatPage`. Each of the
three now takes its client from `useMetadataClient` and calls that method, which
is the whole change — the advisory toast is the client's, so all three surfaces
report identically to the two objectui#6965 routed, with no new UI shape.

What moves with the route, at all three:

- A non-2xx raises `MetadataError` inside the client instead of being read off
`res.ok`. The message is still the server's own, and the ADR-0112
producer-marked `error.userMessage` now outranks the diagnostic `error.message`
where the refusal carries one — the rule objectui#7959 landed on `PackagesPage`,
reaching these surfaces by the same seam rather than by a fourth copy.
`StudioDesignSurface` keeps its field-anchored issue rendering: the client
already carries `error.details.issues` on `MetadataError.issues`, which is what
its `formatMetadataError` reads.
- `failed[]` / `failedCount` / `seedApplied` are read through ONE spelling. The
client unwraps the dispatcher's `{ success, data }` for this route — the one
route whose spec declaration says the body arrives inside one — so the two
server compositions are reconciled before a caller sees them, where the bare
`fetch` sites each carried their own `payload?.data?.x ?? payload?.x` ladder.
- The 2xx batch verdict stays each surface's own, unchanged: `success: false` is
not a refusal on this route, and the three surfaces disagree on purpose about
what a partial or rolled-back batch should say.
61 changes: 46 additions & 15 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { Package as PackageIcon, Sparkles as SparklesIcon } from 'lucide-react';
import { useAdapter } from '../../providers/AdapterProvider.js';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { formatPublishFailures, type PublishFailure } from '../../views/studio-design/metadataError.js';
import { useMetadataClient } from '../../views/metadata-admin/useMetadata.js';
import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js';
import { resolveKeyedI18nLabel } from '../../utils/index.js';
import { resolvePublicShareBase } from '../organizations/resolveHomeUrl.js';
import { ExcelImportBar } from './ExcelImportBar.js';
Expand Down Expand Up @@ -1546,6 +1548,12 @@ export function ChatPane({
}: ChatPaneProps) {
const { t } = useObjectTranslation();
const navigate = useNavigate();
// The advisory seam for this pane's draft-card publish. `useMetadataClient`
// is the layer that hands the console's advisory toast renderer to the
// client, so taking the client from here — rather than firing the route by
// hand — is what makes the runtime authoring gate's per-draft findings reach
// the author at all (objectui#10039).
const metadataClient = useMetadataClient();
// The agent dropdown is a LAUNCHER now (not an in-surface mode toggle): it
// navigates to `/ai/:agent`, so it naturally lists custom agents and can stay
// always-available. Shown only when there's more than one agent to switch to.
Expand Down Expand Up @@ -2430,34 +2438,57 @@ export function ChatPane({
onPublishDrafts={async (packageId) => {
// Promote the conversation's staged drafts to live (ADR-0033 gate —
// the human still clicks). Same call as the floating chat + PackagesPage.
//
// objectui#10039 — through `MetadataClient`, not a bare `fetch`. The
// route answers the runtime authoring gate's per-draft advisories on
// each `published[]` element (objectstack#9343), and the client is
// the seam that reports them: one advisory event per advised item,
// into the same sink, renderer and wording every other write door
// uses. A bare fetch had nothing to report THROUGH — so on the one
// surface where the author never sees the metadata they are
// publishing, the gate's findings were the thing that vanished.
// Same move objectui#6965 / PR objectui#10038 made for the two
// sibling call sites.
try {
const res = await fetch(
`/api/v1/packages/${encodeURIComponent(packageId)}/publish-drafts`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: '{}',
},
);
const payload = await res.json().catch(() => null);
if (!res.ok || payload?.success === false) {
throw new Error(payload?.error?.message || `HTTP ${res.status}`);
const payload = (await metadataClient.publishPackageDrafts(packageId)) as
| (Record<string, unknown> & {
success?: boolean;
error?: { message?: string };
failedCount?: number;
failed?: PublishFailure[];
seedApplied?: { success?: boolean; error?: string; errors?: unknown[] };
})
| null;
// A non-2xx now throws inside the client with the server's own
// message, caught below like any other failure. What is left here
// is the 2xx batch verdict.
if (payload?.success === false) {
// The status is no longer in hand — a non-2xx threw above — so
// the last rung is a sentence rather than "HTTP 200".
throw new Error(
readEnvelopeFailureText(payload) ||
t('console.ai.publishFailed', { defaultValue: 'Publish failed' }),
);
}
const failedCount = payload?.data?.failedCount ?? payload?.failedCount ?? 0;
// One spelling for `failedCount` / `failed[]` / `seedApplied`: the
// client unwraps the dispatcher's `{ success, data }` for this
// route (the one route whose spec declaration says it arrives
// inside one), so the enveloped and unenveloped compositions are
// already reconciled before they get here.
const failedCount = payload?.failedCount ?? 0;
if (failedCount) {
// framework 15.1+ (ADR-0067 D2): a failed batch is ALL-OR-NOTHING
// (rolled back, nothing landed); `failed[]` carries the causal
// item plus batch_aborted markers. Surface the reason — the old
// `String(failedCount)` produced a toast that read just "3".
const failedList = (payload?.data?.failed ?? payload?.failed ?? []) as PublishFailure[];
const failedList = (payload?.failed ?? []) as PublishFailure[];
throw new Error(
failedList.length > 0 ? formatPublishFailures(failedList) : String(failedCount),
);
}
// Surface a seed-load problem (reported under `seedApplied`, never
// thrown) so "Published!" can't hide silently empty tables.
const seedApplied = payload?.data?.seedApplied ?? payload?.seedApplied;
const seedApplied = payload?.seedApplied;
if (seedApplied && seedApplied.success === false) {
toast.warning(
t('console.ai.seedWarn', { defaultValue: 'Published, but some sample data failed to load.' }),
Expand Down
40 changes: 30 additions & 10 deletions packages/app-shell/src/console/ai/PendingDraftsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
* findings via the shared {@link publishHealthFromResponse} instead of a blind
* "Published!", and disappears when the count reaches zero.
*
* objectui#10039 — that publish now goes through `MetadataClient`, not a bare
* `fetch`. The route answers the runtime authoring gate's per-draft advisories
* on each `published[]` element (objectstack#9343) and the client is the seam
* that reports them: it emits one advisory event per advised item into the
* same sink, renderer and wording every other write door uses. A bare fetch
* had nothing to report THROUGH, so every one of those findings was parsed by
* nobody — while the probe findings a few lines below were already shouting.
* objectui#6965 / PR objectui#10038 did this for the two sibling call sites;
* this is the same move, not a second mechanism.
*
* Count freshness: re-read when the package binding changes and whenever the
* turn goes idle (`idle` flips true) — tool results that stage or publish
* drafts land inside a turn, so idle edges are exactly when the count can
Expand All @@ -41,8 +51,10 @@ import { Button } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import { publishHealthFromResponse } from '@object-ui/plugin-chatbot';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { useMetadataClient } from '../../views/metadata-admin/useMetadata.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js';

export interface PendingDraftsBarProps {
/** The conversation's bound package (ADR-0057 A1.a); undefined = not bound yet. */
Expand All @@ -53,6 +65,11 @@ export interface PendingDraftsBarProps {

export function PendingDraftsBar({ packageId, idle }: PendingDraftsBarProps) {
const { refresh } = useMetadata();
// The advisory seam. `useMetadataClient` is the layer that hands the
// console's toast renderer to the client, so taking the client from here —
// rather than firing the route by hand — is what makes the gate's findings
// reach the author at all (objectui#10039).
const client = useMetadataClient();
const { t } = useObjectTranslation();
const [publishing, setPublishing] = useState(false);
// objectui#5801 — the shared pending-drafts source. The hook's bus
Expand All @@ -74,16 +91,19 @@ export function PendingDraftsBar({ packageId, idle }: PendingDraftsBarProps) {
if (!packageId || publishing) return;
setPublishing(true);
try {
const res = await fetch(`/api/v1/packages/${encodeURIComponent(packageId)}/publish-drafts`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: '{}',
});
const body = await res.json().catch(() => undefined);
if (!res.ok) {
let body: unknown;
try {
body = await client.publishPackageDrafts(packageId);
} catch (e) {
// A non-2xx now throws inside the client, carrying the server's own
// message and the parsed body. Same sentence the bare `fetch` showed
// (`parseError` reads it off `error.message`), with the ADR-0112
// producer-marked `userMessage` preferred when the refusal carries
// one — the rule objectui#7959 landed on the sibling call site.
const marked = readEnvelopeFailureText((e as { body?: unknown } | null)?.body);
const message =
(body as { error?: { message?: string } } | undefined)?.error?.message ??
marked ||
(e instanceof Error && e.message ? e.message : '') ||
t('console.ai.pendingDrafts.failed', { defaultValue: 'Publish failed.' });
toast.error(message);
return;
Expand Down Expand Up @@ -113,7 +133,7 @@ export function PendingDraftsBar({ packageId, idle }: PendingDraftsBarProps) {
} finally {
setPublishing(false);
}
}, [packageId, publishing, refresh, t]);
}, [client, packageId, publishing, refresh, t]);

if (!packageId || (count ?? 0) <= 0) return null;

Expand Down
Loading
Loading