Skip to content

Commit 17a3a33

Browse files
fix(entry-explore-mcp-app): surface save errors + resolve pack id via provenance
MoltNet-Diary: 8a53c230-a665-4de1-aff7-42363fa33714 Task-Group: diary-map-app Addresses PR #1229 review (#1229): - onSaveZone no longer fails silently (surfaces saveError) - resolvePackIdByCid uses packs_provenance (pagination-safe) not packs_list - removed dead listZonePacks (YAGNI) - RESTORE_STEP clears activeZoneId as its own invariant
1 parent 8466d6e commit 17a3a33

8 files changed

Lines changed: 88 additions & 83 deletions

File tree

apps/mcp-host-e2e/manual/diary-map-fixture.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ async function main() {
160160
url.searchParams.set('autorun', '1');
161161
url.searchParams.set('server', `${harness.mcpBaseUrl}/mcp`);
162162
url.searchParams.set('clientId', agent.clientId);
163+
// Throwaway fixture credentials for a local ephemeral stack ONLY. Putting a
164+
// secret in URL params leaks it to browser history / logs / Referer headers —
165+
// never do this with real agent credentials.
163166
url.searchParams.set('clientSecret', agent.clientSecret);
164167
url.searchParams.set(
165168
'args',

libs/entry-explore-mcp-app/src/MapApp.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function pivotsForZone(
2929
): Pivot[] {
3030
return zones
3131
.filter((zone) => zone.id !== activeZoneId)
32-
.slice(0, 4)
32+
.slice(0, 4) // V1: intentionally cap at 4 pivots to keep the row compact
3333
.map((zone) => ({ id: zone.id, label: `→ ${zone.label}` }));
3434
}
3535

@@ -62,6 +62,7 @@ export function MapApp() {
6262
const [entries, setEntries] = useState<EntryCardEntry[]>([]);
6363
const [loadingEntries, setLoadingEntries] = useState(false);
6464
const [saving, setSaving] = useState(false);
65+
const [saveError, setSaveError] = useState<string | null>(null);
6566

6667
const adapter = useMemo(() => (app ? new McpDiaryAdapter(app) : null), [app]);
6768

@@ -107,6 +108,7 @@ export function MapApp() {
107108
async (zone: typeof activeZone) => {
108109
if (!adapter || !map || !zone) return;
109110
setSaving(true);
111+
setSaveError(null);
110112
try {
111113
if (!zone.packId) {
112114
const draft = await adapter.createZonePack({
@@ -115,6 +117,12 @@ export function MapApp() {
115117
entryIds: zone.entryIds,
116118
provenance: zone.provenance,
117119
});
120+
// createZonePack resolves the UUID from the CID; if that came back
121+
// empty, pinning later would no-op — surface it rather than silently
122+
// marking the zone saved.
123+
if (!draft.packId) {
124+
throw new Error('Could not resolve the saved pack id.');
125+
}
118126
dispatch({
119127
type: 'MATERIALIZE_ZONE',
120128
zoneId: zone.id,
@@ -124,6 +132,10 @@ export function MapApp() {
124132
await adapter.setZonePinned(zone.packId, true);
125133
dispatch({ type: 'PIN_ZONE', zoneId: zone.id, pinned: true });
126134
}
135+
} catch (err) {
136+
// Don't let the save fail silently (the button would just reset to
137+
// "Save this zone" and re-clicking would retry blindly). Show why.
138+
setSaveError(err instanceof Error ? err.message : 'Save failed.');
127139
} finally {
128140
setSaving(false);
129141
}
@@ -163,10 +175,7 @@ export function MapApp() {
163175
<Breadcrumb
164176
trail={map.trail}
165177
activeStep={map.activeStep}
166-
onRestore={(index) => {
167-
dispatch({ type: 'RESTORE_STEP', index });
168-
dispatch({ type: 'SHOW_OVERVIEW' });
169-
}}
178+
onRestore={(index) => dispatch({ type: 'RESTORE_STEP', index })}
170179
/>
171180

172181
{activeZone ? (
@@ -202,6 +211,7 @@ export function MapApp() {
202211
zone={activeZone}
203212
onSaveZone={(zone) => void onSaveZone(zone)}
204213
saving={saving}
214+
saveError={saveError}
205215
/>
206216
</Shell>
207217
);

libs/entry-explore-mcp-app/src/adapter/mcp-adapter.test.ts

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -122,21 +122,16 @@ describe('McpDiaryAdapter.listTags', () => {
122122
});
123123

124124
describe('McpDiaryAdapter.createZonePack', () => {
125-
it('creates an UNPINNED pack with provenance, then resolves its id by CID', async () => {
126-
// packs_create returns only packCid (no id); the adapter must look the id
127-
// up via packs_list because packs_update (pin) needs the UUID.
125+
it('creates an UNPINNED pack with provenance, then resolves its id from the CID', async () => {
126+
// packs_create returns only packCid (no id); the adapter resolves the UUID
127+
// via packs_provenance (metadata.rootPackId), not a paginated packs_list.
128128
const { app, calls } = caller((input) => {
129129
if (input.name === 'packs_create') {
130130
return Promise.resolve(textResult({ packCid: 'bafy-zone' }));
131131
}
132-
if (input.name === 'packs_list') {
132+
if (input.name === 'packs_provenance') {
133133
return Promise.resolve(
134-
textResult({
135-
items: [
136-
{ id: 'other', packCid: 'bafy-other' },
137-
{ id: 'pack-1', packCid: 'bafy-zone' },
138-
],
139-
}),
134+
textResult({ metadata: { rootPackId: 'pack-1' } }),
140135
);
141136
}
142137
return Promise.resolve(textResult({}));
@@ -170,14 +165,36 @@ describe('McpDiaryAdapter.createZonePack', () => {
170165
{ entry_id: 'e2', rank: 1 },
171166
{ entry_id: 'e1', rank: 2 },
172167
]);
173-
// Second call resolves the UUID from packs_list, matching on packCid.
174-
expect(calls[1].name).toBe('packs_list');
168+
// The UUID is resolved deterministically from the CID via provenance.
169+
expect(calls[1].name).toBe('packs_provenance');
170+
expect(calls[1].arguments).toMatchObject({ pack_cid: 'bafy-zone' });
175171
expect(draft).toEqual({
176172
packId: 'pack-1',
177173
packCid: 'bafy-zone',
178174
pinned: false,
179175
});
180176
});
177+
178+
it('returns an empty packId when packs_create yields no CID (no provenance call)', async () => {
179+
const { app, calls } = caller((input) => {
180+
if (input.name === 'packs_create') {
181+
return Promise.resolve(textResult({}));
182+
}
183+
return Promise.resolve(textResult({}));
184+
});
185+
const adapter = new McpDiaryAdapter(app);
186+
187+
const draft = await adapter.createZonePack({
188+
diaryId: 'd1',
189+
label: 'X',
190+
entryIds: ['e1'],
191+
provenance: { basis: 'x', searches: [] },
192+
});
193+
194+
expect(draft.packId).toBe('');
195+
// No CID -> we never attempt provenance resolution.
196+
expect(calls.some((c) => c.name === 'packs_provenance')).toBe(false);
197+
});
181198
});
182199

183200
describe('McpDiaryAdapter.setZonePinned', () => {
@@ -204,27 +221,3 @@ describe('McpDiaryAdapter.setZonePinned', () => {
204221
).toBeGreaterThan(Date.now());
205222
});
206223
});
207-
208-
describe('McpDiaryAdapter.listZonePacks', () => {
209-
it('returns only diary-map-zone packs', async () => {
210-
const { app } = caller(() =>
211-
Promise.resolve(
212-
textResult({
213-
items: [
214-
{
215-
id: 'p1',
216-
pinned: false,
217-
params: { kind: 'diary-map-zone', label: 'A' },
218-
},
219-
{ id: 'p2', pinned: true, params: { kind: 'compile' } },
220-
],
221-
}),
222-
),
223-
);
224-
const adapter = new McpDiaryAdapter(app);
225-
226-
const out = await adapter.listZonePacks('d1');
227-
228-
expect(out).toEqual([{ packId: 'p1', label: 'A', pinned: false }]);
229-
});
230-
});

libs/entry-explore-mcp-app/src/adapter/mcp-adapter.ts

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* existing deterministic server tools (`entries_list`, `entries_search`,
77
* `diary_tags`). Zone materialization/validation wraps the existing packs tools
88
* (`packs_create`, `packs_update`, `packs_list`) so a zone becomes an unpinned
9-
* draft pack carrying its search provenance in `params`, then a pinned pack once
10-
* the human validates it.
9+
* draft pack carrying its search provenance in `params` (`packs_create`), then a
10+
* pinned pack once the human validates it (`packs_update`). The pack UUID is
11+
* resolved from the create CID via `packs_provenance`.
1112
*
1213
* Tool results arrive as `{ content: [{ type:'text', text }], structuredContent }`;
1314
* {@link parseToolJson} reads either shape (mirrors libs/task-mcp-app).
@@ -176,28 +177,27 @@ export class McpDiaryAdapter implements DiaryDataAdapter {
176177
const json = parseToolJson(result);
177178
const packCid = typeof json.packCid === 'string' ? json.packCid : '';
178179
// packs_create returns only packCid (CustomPackResult), not the pack UUID,
179-
// but packs_update (pin) needs the UUID. Resolve it from packs_list by CID.
180-
const packId = packCid
181-
? await this.resolvePackIdByCid(input.diaryId, packCid)
182-
: '';
180+
// but packs_update (pin) needs the UUID. Resolve it deterministically from
181+
// the CID via packs_provenance (its metadata.rootPackId IS the UUID) —
182+
// never via packs_list, which paginates (default limit 20) and could miss a
183+
// freshly-created pack in a diary with many packs.
184+
const packId = packCid ? await this.resolvePackIdByCid(packCid) : '';
183185
return { packId, packCid, pinned: false };
184186
}
185187

186-
/** Resolve a pack's UUID from its CID via packs_list (needed to pin later). */
187-
private async resolvePackIdByCid(
188-
diaryId: string,
189-
packCid: string,
190-
): Promise<string> {
188+
/**
189+
* Resolve a pack's UUID from its CID via packs_provenance, whose
190+
* `metadata.rootPackId` is the pack UUID. Deterministic and pagination-free
191+
* (packs_update needs the UUID to pin). Returns '' if it can't be resolved.
192+
*/
193+
private async resolvePackIdByCid(packCid: string): Promise<string> {
191194
const result = await this.app.callServerTool({
192-
name: 'packs_list',
193-
arguments: { diary_id: diaryId },
195+
name: 'packs_provenance',
196+
arguments: { pack_cid: packCid, depth: 0 },
194197
});
195198
const json = parseToolJson(result);
196-
const items = Array.isArray(json.items) ? json.items : [];
197-
const match = items
198-
.map((item) => asRecord(item))
199-
.find((item) => item.packCid === packCid);
200-
return match && typeof match.id === 'string' ? match.id : '';
199+
const metadata = asRecord(json.metadata);
200+
return typeof metadata.rootPackId === 'string' ? metadata.rootPackId : '';
201201
}
202202

203203
/** Pin (validate) or unpin a zone's draft pack. */
@@ -214,27 +214,4 @@ export class McpDiaryAdapter implements DiaryDataAdapter {
214214
}),
215215
});
216216
}
217-
218-
/** List the diary's draft zone packs (unpinned, kind=diary-map-zone). */
219-
async listZonePacks(
220-
diaryId: string,
221-
): Promise<Array<{ packId: string; label: string; pinned: boolean }>> {
222-
const result = await this.app.callServerTool({
223-
name: 'packs_list',
224-
arguments: { diary_id: diaryId },
225-
});
226-
const json = parseToolJson(result);
227-
const items = Array.isArray(json.items) ? json.items : [];
228-
return items
229-
.map((item) => asRecord(item))
230-
.filter((item) => asRecord(item.params).kind === 'diary-map-zone')
231-
.map((item) => ({
232-
packId: typeof item.id === 'string' ? item.id : '',
233-
label:
234-
typeof asRecord(item.params).label === 'string'
235-
? (asRecord(item.params).label as string)
236-
: 'Zone',
237-
pinned: item.pinned === true,
238-
}));
239-
}
240217
}

libs/entry-explore-mcp-app/src/components/NextSteps.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface NextStepsProps {
1313
zone: Zone | null;
1414
onSaveZone: (zone: Zone) => void;
1515
saving: boolean;
16+
/** Surfaced when the last save attempt failed, so it isn't a silent no-op. */
17+
saveError?: string | null;
1618
}
1719

1820
/**
@@ -26,6 +28,7 @@ export function NextSteps({
2628
zone,
2729
onSaveZone,
2830
saving,
31+
saveError,
2932
}: NextStepsProps) {
3033
const saved = Boolean(zone?.packId);
3134
return (
@@ -59,6 +62,11 @@ export function NextSteps({
5962
</button>
6063
) : null}
6164
</div>
65+
{saveError ? (
66+
<p className="next-steps-error" role="alert">
67+
{saveError}
68+
</p>
69+
) : null}
6270
</div>
6371
);
6472
}

libs/entry-explore-mcp-app/src/state/map.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ describe('mapReducer', () => {
107107
expect(activeTrailStep(next).why).toBe('narrowed to autonomy');
108108
});
109109

110-
it('RESTORE_STEP moves back with ZERO refetch and truncates the future', () => {
110+
it('RESTORE_STEP moves back with ZERO refetch, truncates the future, and clears the active zone', () => {
111111
let next = mapReducer(map, {
112112
type: 'REFINE_SEARCH',
113113
label: 'autonomy',
@@ -122,12 +122,17 @@ describe('mapReducer', () => {
122122
query: 'identity',
123123
visibleEntryIds: ['e3'],
124124
})!;
125+
// Focus a zone so we can assert RESTORE_STEP clears it on its own.
126+
next = mapReducer(next, { type: 'FOCUS_ZONE', zoneId: 'a' })!;
125127
expect(next.trail).toHaveLength(3);
128+
expect(next.activeZoneId).toBe('a');
126129

127130
const restored = mapReducer(next, { type: 'RESTORE_STEP', index: 0 })!;
128131
// The cached visible set is reused verbatim — no new ids, no fetch needed.
129132
expect(restored.activeStep).toBe(0);
130133
expect(restored.trail).toHaveLength(1);
134+
// Returning to a past step returns to its overview — zone cleared.
135+
expect(restored.activeZoneId).toBeNull();
131136
expect(activeTrailStep(restored).visibleEntryIds).toEqual([
132137
'e1',
133138
'e2',

libs/entry-explore-mcp-app/src/state/map.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,14 @@ export function mapReducer(
198198

199199
case 'RESTORE_STEP': {
200200
if (action.index < 0 || action.index >= state.trail.length) return state;
201+
// Restoring a past camera position always returns to that step's
202+
// overview, so clearing the focused zone is part of this action's own
203+
// invariant (callers must not have to pair it with SHOW_OVERVIEW).
201204
return {
202205
...state,
203206
trail: state.trail.slice(0, action.index + 1),
204207
activeStep: action.index,
208+
activeZoneId: null,
205209
};
206210
}
207211

libs/entry-explore-mcp-app/src/styles.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,11 @@ body {
285285
opacity: 0.5;
286286
cursor: default;
287287
}
288+
.next-steps-error {
289+
margin: 0;
290+
font-size: 0.82rem;
291+
color: var(--molt-error, #f04060);
292+
}
288293

289294
/* ---- empty / fallback ---- */
290295
.fallback {

0 commit comments

Comments
 (0)