ofw-mcp 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -3
- package/dist/bundle.js +644 -118
- package/dist/cache/store.js +79 -1
- package/dist/config.js +18 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +4 -0
- package/dist/tools/draft-freshness.js +46 -13
- package/dist/tools/lifecycle.js +277 -0
- package/dist/tools/messages.js +447 -135
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +32 -13
package/dist/tools/messages.js
CHANGED
|
@@ -2,10 +2,11 @@ import { z } from 'zod';
|
|
|
2
2
|
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
|
|
3
3
|
import { buildFreshness } from './freshness.js';
|
|
4
4
|
import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
|
|
5
|
+
import { FOLDER_TYPE, newDraftKey, persistFolderIds, probeIds, resolveDraftKey } from './lifecycle.js';
|
|
5
6
|
import { getFolderVerifiedAt } from '../sync.js';
|
|
6
7
|
import { buildInlineDelivery, tryExtract } from './delivery.js';
|
|
7
8
|
import { resolveDownloadMime } from './attachments.js';
|
|
8
|
-
import { getAllowMarkRead, getAttachmentsDir, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
|
|
9
|
+
import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
|
|
9
10
|
import { basename, join } from 'node:path';
|
|
10
11
|
import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
11
12
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
@@ -45,7 +46,10 @@ const MessageDetailSchema = z.looseObject({
|
|
|
45
46
|
// The detail payload carries its own owning folder ({id, name}). We read the
|
|
46
47
|
// id to label a live-fetched message sent-vs-inbox instead of blindly
|
|
47
48
|
// defaulting to inbox — see the folder derivation in ofw_get_message.
|
|
48
|
-
|
|
49
|
+
// Same union as ServerDraftSchema's, for the same reason — OFW types this id
|
|
50
|
+
// as a string on the folders listing and a number on message detail. Lenient
|
|
51
|
+
// here, so a mismatch only warns, but it would warn on EVERY live fetch.
|
|
52
|
+
folder: z.looseObject({ id: z.union([z.string(), z.number()]) }).optional(),
|
|
49
53
|
});
|
|
50
54
|
// Attachment-backfill detail fetch reads only `files`.
|
|
51
55
|
const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
|
|
@@ -62,11 +66,6 @@ const FolderCountsSchema = z.looseObject({
|
|
|
62
66
|
count: z.number().optional(),
|
|
63
67
|
})).optional(),
|
|
64
68
|
});
|
|
65
|
-
const FOLDER_TYPE = {
|
|
66
|
-
inbox: 'INBOX',
|
|
67
|
-
sent: 'SENT_MESSAGES',
|
|
68
|
-
drafts: 'DRAFTS',
|
|
69
|
-
};
|
|
70
69
|
/**
|
|
71
70
|
* Cap on per-id probes in one ofw_check_freshness call.
|
|
72
71
|
*
|
|
@@ -126,6 +125,64 @@ async function draftsFreshness(cache) {
|
|
|
126
125
|
: 'unverified';
|
|
127
126
|
return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
|
|
128
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Description shared by every read tool's `autoRefresh` argument, so the escape
|
|
130
|
+
* hatch from an UNVERIFIED_EMPTY refusal reads identically wherever it appears.
|
|
131
|
+
*/
|
|
132
|
+
const AUTO_REFRESH_DESC = 'If the result comes back EMPTY from a cache that is not verified-fresh, sync the backing folders first and answer from the refreshed cache instead of refusing. Defaults to the OFW_AUTO_REFRESH env var (false unless set), in which case the call refuses with result:"UNVERIFIED_EMPTY" and names the remedy. Costs OFW requests when it fires.';
|
|
133
|
+
/**
|
|
134
|
+
* Run a cached read, and never let it answer "nothing there" on the strength of
|
|
135
|
+
* a cache that cannot vouch for itself.
|
|
136
|
+
*
|
|
137
|
+
* The failure this closes: an empty result set carrying a 207-minute-old
|
|
138
|
+
* `freshness` block is shaped IDENTICALLY to a verified "nothing matched". Skim
|
|
139
|
+
* past the warning and the natural next sentence is "no, that message was never
|
|
140
|
+
* sent" — a false negative stated as fact about a court-visible record. A false
|
|
141
|
+
* negative is more dangerous than a refusal precisely because it reads as a
|
|
142
|
+
* definitive answer, so the bias is: refuse, and say how to get a real one.
|
|
143
|
+
*
|
|
144
|
+
* `autoRefresh` turns the refusal into a sync-then-retry. If that still cannot
|
|
145
|
+
* make the read verifiable (the budget paused, OFW was unreachable), the refusal
|
|
146
|
+
* fires anyway — a refresh that did not work must not be treated as one that did.
|
|
147
|
+
*
|
|
148
|
+
* Non-empty results are never touched: a stale cache that DID find something is
|
|
149
|
+
* evidence of presence, and its `freshness` block already labels its age.
|
|
150
|
+
*/
|
|
151
|
+
async function guardedCacheRead(o) {
|
|
152
|
+
let value = await o.read();
|
|
153
|
+
let refreshed = false;
|
|
154
|
+
const unverifiable = (v) => o.isEmpty(v) && v.freshness.staleness !== 'fresh';
|
|
155
|
+
if (unverifiable(value) && o.autoRefresh) {
|
|
156
|
+
await syncAll(o.client, {
|
|
157
|
+
folders: o.folders,
|
|
158
|
+
// Same ceiling ofw_sync_messages applies: an automatic refresh must never
|
|
159
|
+
// stamp unread inbox messages as a side effect of a list read.
|
|
160
|
+
fetchUnreadBodies: getAllowMarkRead() && getFetchUnreadBodies(),
|
|
161
|
+
maxRequests: getSyncMaxRequests(),
|
|
162
|
+
}, o.cache);
|
|
163
|
+
refreshed = true;
|
|
164
|
+
value = await o.read();
|
|
165
|
+
}
|
|
166
|
+
return { value, refreshed, unverifiedEmpty: unverifiable(value) };
|
|
167
|
+
}
|
|
168
|
+
/** The structured non-result returned instead of an unverifiable empty list. */
|
|
169
|
+
function unverifiedEmptyResponse(input) {
|
|
170
|
+
const { freshness } = input;
|
|
171
|
+
const age = freshness.ageSeconds === null
|
|
172
|
+
? 'it has never been checked against OurFamilyWizard'
|
|
173
|
+
: `it was last verified ${freshness.ageSeconds < 60 ? `${freshness.ageSeconds} sec` : `${Math.round(freshness.ageSeconds / 60)} min`} ago`;
|
|
174
|
+
const refreshClause = input.refreshed
|
|
175
|
+
? ' An automatic refresh ran on this call and did NOT make the result verifiable (the sync paused or skipped this folder), so the refusal stands.'
|
|
176
|
+
: '';
|
|
177
|
+
return jsonErrorResponse({
|
|
178
|
+
result: 'UNVERIFIED_EMPTY',
|
|
179
|
+
reason: `No ${input.what} were found, but the backing cache is "${freshness.staleness}" — ${age}. Refusing to report absence from unverified data: an empty result from a stale cache is indistinguishable from a verified "nothing there", and repeating it as one asserts a false negative about a legal record.${refreshClause}`,
|
|
180
|
+
remedy: input.remedy,
|
|
181
|
+
complete: false,
|
|
182
|
+
freshness,
|
|
183
|
+
...input.extra,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
129
186
|
/**
|
|
130
187
|
* Decide whether fetching this message's body from OFW would stamp the record,
|
|
131
188
|
* and refuse when the caller (or the deployment) has opted out of that.
|
|
@@ -185,8 +242,8 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
185
242
|
return jsonResponse({ folders: data, freshness });
|
|
186
243
|
});
|
|
187
244
|
server.registerTool('ofw_list_messages', {
|
|
188
|
-
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages.
|
|
189
|
-
annotations: { readOnlyHint:
|
|
245
|
+
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
|
|
246
|
+
annotations: { readOnlyHint: false },
|
|
190
247
|
inputSchema: {
|
|
191
248
|
folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
192
249
|
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
@@ -194,6 +251,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
194
251
|
since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
|
|
195
252
|
until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
|
|
196
253
|
q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
|
|
254
|
+
autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
|
|
197
255
|
},
|
|
198
256
|
}, async (args) => {
|
|
199
257
|
const page = args.page ?? 1;
|
|
@@ -207,40 +265,76 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
207
265
|
else if (folderArg === 'both')
|
|
208
266
|
folder = undefined;
|
|
209
267
|
else {
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
return
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache. No lookup was performed — this empty result says nothing about what is in the cache.',
|
|
268
|
+
// A rejected argument is an ERROR, not a result. Returning `messages: []`
|
|
269
|
+
// here — even with a note attached — hands back the one shape this whole
|
|
270
|
+
// mechanism exists to eliminate: an empty list that looks like an answer.
|
|
271
|
+
return jsonErrorResponse({
|
|
272
|
+
result: 'INVALID_FOLDER',
|
|
273
|
+
reason: `folderId must be "inbox", "sent", or "both" (got ${JSON.stringify(folderArg)}). Numeric OFW folder IDs are not supported by the cache.`,
|
|
274
|
+
remedy: 'Re-call with folderId omitted (searches both) or set to one of the three accepted names.',
|
|
275
|
+
complete: false,
|
|
276
|
+
note: 'No lookup was performed. This says NOTHING about what is in the cache — do not read it as "no messages".',
|
|
220
277
|
});
|
|
221
278
|
}
|
|
222
279
|
const cache = cacheProvider();
|
|
280
|
+
const folders = folder === undefined ? ['inbox', 'sent'] : [folder];
|
|
223
281
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
282
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
283
|
+
client,
|
|
284
|
+
cache,
|
|
285
|
+
folders,
|
|
286
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
287
|
+
isEmpty: (v) => v.total === 0,
|
|
288
|
+
read: async () => {
|
|
289
|
+
const total = await cache.countMessages(filter);
|
|
290
|
+
// Reconcile each row's read state at read time: the cached list flags
|
|
291
|
+
// can be stale (a message read after it was first scraped), so `read`
|
|
292
|
+
// is derived from the record's own `viewedAt`/`fetchedBodyAt` and
|
|
293
|
+
// `listData` is forced to agree — see withReadState.
|
|
294
|
+
const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
295
|
+
// Served from the local cache, so the result must say how old it is and
|
|
296
|
+
// whether anything vouches for it — a caller cannot state current state
|
|
297
|
+
// from this payload without either re-reading or surfacing the caveat.
|
|
298
|
+
const freshness = await buildFreshness(cache, { source: 'cache', folders });
|
|
299
|
+
return { messages, total, freshness };
|
|
300
|
+
},
|
|
236
301
|
});
|
|
237
|
-
|
|
302
|
+
if (unverifiedEmpty) {
|
|
303
|
+
return unverifiedEmptyResponse({
|
|
304
|
+
what: 'messages matching these filters',
|
|
305
|
+
freshness: value.freshness,
|
|
306
|
+
refreshed,
|
|
307
|
+
remedy: `Call ofw_sync_messages(folders:${JSON.stringify(folders)}) and retry, or re-call this tool with autoRefresh:true. ofw_check_freshness is the cheap live alternative when you only need to confirm a specific message.`,
|
|
308
|
+
extra: { page, size, filters: { folderId: folderArg, since: args.since, until: args.until, q: args.q } },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const { messages, total, freshness } = value;
|
|
312
|
+
// `complete` describes the RESULT SET, not the sync: true only when this
|
|
313
|
+
// payload holds every matching message the server has. `syncComplete` /
|
|
314
|
+
// `historyComplete` in `freshness` describe the walk that filled the cache
|
|
315
|
+
// and cannot answer "have I now seen all of them?" on their own — a caller
|
|
316
|
+
// needs one boolean to check before saying "you have N messages".
|
|
317
|
+
const fullSlice = page === 1 && messages.length === total;
|
|
318
|
+
const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
|
|
319
|
+
const payload = { messages, total, page, size, complete, freshness };
|
|
320
|
+
if (!complete) {
|
|
321
|
+
payload.completeNote = [
|
|
322
|
+
!fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
|
|
323
|
+
freshness.staleness !== 'fresh' ? `the cache is "${freshness.staleness}", so newer messages may exist on OurFamilyWizard` : null,
|
|
324
|
+
!freshness.historyComplete ? 'older history is still being backfilled, so the cache does not yet hold every message' : null,
|
|
325
|
+
].filter((r) => r !== null)
|
|
326
|
+
.join('; ')
|
|
327
|
+
.concat('. Do not state a total or an absence from this result without resolving that first.');
|
|
328
|
+
}
|
|
238
329
|
if (total === 0) {
|
|
239
|
-
payload.note = 'No messages match these filters. If you expected results,
|
|
330
|
+
payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders — so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
|
|
240
331
|
}
|
|
241
332
|
else if (page * size < total) {
|
|
242
333
|
payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
|
|
243
334
|
}
|
|
335
|
+
if (refreshed) {
|
|
336
|
+
payload.autoRefreshed = true;
|
|
337
|
+
}
|
|
244
338
|
return jsonResponse(payload);
|
|
245
339
|
});
|
|
246
340
|
server.registerTool('ofw_get_message', {
|
|
@@ -281,6 +375,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
281
375
|
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
282
376
|
// ofw_delete_draft to assert you are editing THIS version.
|
|
283
377
|
revision: draftRevision(draftRow),
|
|
378
|
+
// Stable logical identity. Survives the create-then-delete id churn of
|
|
379
|
+
// editing AND the transition to sent — pass it to ofw_status to ask
|
|
380
|
+
// "what happened to the thing I was working on?". Null when this draft
|
|
381
|
+
// was never written through this tool (e.g. authored in the web app).
|
|
382
|
+
draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
|
|
284
383
|
cacheStatus,
|
|
285
384
|
// False = this draft's existence and unsent status are remembered from
|
|
286
385
|
// a cache, not confirmed on OFW. Call ofw_check_freshness before
|
|
@@ -467,6 +566,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
467
566
|
}, SentDetailSchema, 'ofw_send_message');
|
|
468
567
|
let persisted = null;
|
|
469
568
|
let verifyNote = null;
|
|
569
|
+
let sentDraftKey = null;
|
|
470
570
|
if (newId !== null) {
|
|
471
571
|
verifyNote = verifyWriteLanded('message', { subject, body }, detail);
|
|
472
572
|
persisted = {
|
|
@@ -483,6 +583,21 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
483
583
|
listData: detail,
|
|
484
584
|
};
|
|
485
585
|
await cache.upsertMessage(persisted);
|
|
586
|
+
// Extend the draft's identity chain onto the SENT message. Without this
|
|
587
|
+
// the chain would dead-end at the last draft id and "what happened to the
|
|
588
|
+
// draft I was editing?" would answer `deleted` — technically true of that
|
|
589
|
+
// id, and the exact wrong impression. With it, resolving the key lands on
|
|
590
|
+
// the sent message and reports state:"sent" with its sentAt.
|
|
591
|
+
if (draftRef !== undefined) {
|
|
592
|
+
const prior = await cache.getDraftLineageById(draftRef);
|
|
593
|
+
const now = new Date().toISOString();
|
|
594
|
+
const key = prior?.draftKey ?? newDraftKey();
|
|
595
|
+
if (prior === null) {
|
|
596
|
+
await cache.recordDraftLineage({ id: draftRef, draftKey: key, previousId: null, recordedAt: now });
|
|
597
|
+
}
|
|
598
|
+
await cache.recordDraftLineage({ id: newId, draftKey: key, previousId: draftRef, recordedAt: now });
|
|
599
|
+
sentDraftKey = key;
|
|
600
|
+
}
|
|
486
601
|
// Link attached files to the new message in the attachments cache.
|
|
487
602
|
// We may not have full metadata if the upload happened in a prior
|
|
488
603
|
// session — fall back to what we know.
|
|
@@ -513,7 +628,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
513
628
|
await deleteOFWMessages(client, [draftRef]);
|
|
514
629
|
await cache.deleteDraft(draftRef);
|
|
515
630
|
}
|
|
516
|
-
const responseObj = persisted
|
|
631
|
+
const responseObj = persisted === null
|
|
632
|
+
? raw
|
|
633
|
+
: { ...persisted, ...(sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {}) };
|
|
517
634
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
|
|
518
635
|
const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
|
|
519
636
|
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
@@ -587,38 +704,72 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
587
704
|
};
|
|
588
705
|
}
|
|
589
706
|
server.registerTool('ofw_list_drafts', {
|
|
590
|
-
description: 'List draft messages from the local OurFamilyWizard cache.
|
|
591
|
-
annotations: { readOnlyHint:
|
|
707
|
+
description: 'List draft messages from the local OurFamilyWizard cache. Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" — check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead. For a live, one-call answer prefer ofw_status(includeDraftInventory:true).',
|
|
708
|
+
annotations: { readOnlyHint: false },
|
|
592
709
|
inputSchema: {
|
|
593
710
|
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
594
711
|
size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
|
|
712
|
+
autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
|
|
595
713
|
},
|
|
596
714
|
}, async (args) => {
|
|
597
715
|
const page = args.page ?? 1;
|
|
598
716
|
const size = args.size ?? 50;
|
|
599
717
|
const cache = cacheProvider();
|
|
600
|
-
const {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
718
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
719
|
+
client,
|
|
720
|
+
cache,
|
|
721
|
+
folders: ['drafts'],
|
|
722
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
723
|
+
isEmpty: (v) => v.total === 0,
|
|
724
|
+
read: async () => {
|
|
725
|
+
const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
726
|
+
const rows = await cache.listDrafts({ page, size });
|
|
727
|
+
const total = await cache.countDrafts();
|
|
728
|
+
// One batch lookup for the whole page — on the Durable Object backend a
|
|
729
|
+
// per-draft lineage read would be a subrequest each.
|
|
730
|
+
const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
|
|
731
|
+
// Every draft carries the concurrency token to echo back on a write, its
|
|
732
|
+
// stable identity across edits, and whether the last sync actually
|
|
733
|
+
// compared this cache against OFW.
|
|
734
|
+
const drafts = rows.map((d) => ({
|
|
735
|
+
...d,
|
|
736
|
+
revision: draftRevision(d),
|
|
737
|
+
draftKey: keyById.get(d.id) ?? null,
|
|
738
|
+
cacheStatus,
|
|
739
|
+
serverConfirmed,
|
|
740
|
+
asOf: freshness.asOf,
|
|
741
|
+
}));
|
|
742
|
+
return { drafts, total, freshness, serverConfirmed };
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
if (unverifiedEmpty) {
|
|
746
|
+
return unverifiedEmptyResponse({
|
|
747
|
+
what: 'drafts',
|
|
748
|
+
freshness: value.freshness,
|
|
749
|
+
refreshed,
|
|
750
|
+
remedy: 'Call ofw_sync_messages(folders:["drafts"]) and retry, re-call with autoRefresh:true, or use ofw_status(includeDraftInventory:true) for a single live answer.',
|
|
751
|
+
extra: { page, size },
|
|
617
752
|
});
|
|
618
753
|
}
|
|
619
|
-
const
|
|
754
|
+
const { drafts, total, freshness, serverConfirmed } = value;
|
|
755
|
+
// True only when this payload IS the full server-side draft set: verified
|
|
756
|
+
// against OFW inside the freshness window AND not a slice of a larger list.
|
|
757
|
+
const fullSlice = page === 1 && drafts.length === total;
|
|
758
|
+
const complete = serverConfirmed && fullSlice;
|
|
759
|
+
const payload = { drafts, total, page, size, complete, freshness };
|
|
760
|
+
if (!complete) {
|
|
761
|
+
payload.completeNote = [
|
|
762
|
+
!fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
|
|
763
|
+
!serverConfirmed ? 'the drafts cache has not been confirmed against OurFamilyWizard inside the freshness window' : null,
|
|
764
|
+
].filter((r) => r !== null)
|
|
765
|
+
.join('; ')
|
|
766
|
+
.concat('. Do NOT state a draft count from this result — call ofw_status(includeDraftInventory:true) for a live, complete one.');
|
|
767
|
+
}
|
|
620
768
|
if (!serverConfirmed) {
|
|
621
|
-
payload.note = 'serverConfirmed:false — these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited or
|
|
769
|
+
payload.note = 'serverConfirmed:false — these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited, deleted or SENT in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_status / ofw_check_freshness (cheap, live) or ofw_sync_messages first. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
|
|
770
|
+
}
|
|
771
|
+
if (refreshed) {
|
|
772
|
+
payload.autoRefreshed = true;
|
|
622
773
|
}
|
|
623
774
|
return jsonResponse(payload);
|
|
624
775
|
});
|
|
@@ -683,6 +834,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
683
834
|
let replaceNote = null;
|
|
684
835
|
let verifyNote = null;
|
|
685
836
|
let newRevision = null;
|
|
837
|
+
let draftKey = null;
|
|
686
838
|
// Fields accepted on the write that the saved draft must carry — or their
|
|
687
839
|
// loss must be reported. Never a silent drop (Defect 3).
|
|
688
840
|
const warnings = [];
|
|
@@ -710,6 +862,34 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
710
862
|
// The revision is now computed from the server-authoritative detail, so it
|
|
711
863
|
// is the value a subsequent read/verify will observe (Defect 1).
|
|
712
864
|
newRevision = draftRevision(persisted);
|
|
865
|
+
// Carry the logical identity across the id change. Replacing a draft mints
|
|
866
|
+
// a NEW OFW id every time (create-then-delete — see the note below), so
|
|
867
|
+
// ten edits produced ten unrelated ids and there was no way to ask what
|
|
868
|
+
// became of the one you started with. The key is minted on first sight —
|
|
869
|
+
// including retroactively for the draft being replaced, so a draft that
|
|
870
|
+
// predates this mechanism joins a chain the moment it is edited.
|
|
871
|
+
const now = new Date().toISOString();
|
|
872
|
+
if (args.messageId !== undefined) {
|
|
873
|
+
const prior = await cache.getDraftLineageById(args.messageId);
|
|
874
|
+
if (prior !== null) {
|
|
875
|
+
draftKey = prior.draftKey;
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
draftKey = newDraftKey();
|
|
879
|
+
await cache.recordDraftLineage({
|
|
880
|
+
id: args.messageId, draftKey, previousId: null, recordedAt: now,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
draftKey = newDraftKey();
|
|
886
|
+
}
|
|
887
|
+
await cache.recordDraftLineage({
|
|
888
|
+
id: newId,
|
|
889
|
+
draftKey,
|
|
890
|
+
previousId: args.messageId ?? null,
|
|
891
|
+
recordedAt: now,
|
|
892
|
+
});
|
|
713
893
|
// Audit every field the caller supplied against what actually landed, so a
|
|
714
894
|
// silent normalization becomes a visible warning rather than a surprise.
|
|
715
895
|
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
@@ -767,6 +947,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
767
947
|
...persisted,
|
|
768
948
|
inReplyTo: persisted.replyToId,
|
|
769
949
|
revision: newRevision,
|
|
950
|
+
// The id above is volatile — it changes on every edit. `draftKey` is
|
|
951
|
+
// not: pass it to ofw_status to resolve the chain's CURRENT id, or to
|
|
952
|
+
// find out that the draft was sent and when.
|
|
953
|
+
draftKey,
|
|
954
|
+
previousId: args.messageId ?? null,
|
|
770
955
|
cacheStatus: 'fresh',
|
|
771
956
|
serverConfirmed: true,
|
|
772
957
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
@@ -806,28 +991,49 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
806
991
|
return textResponse(guard.note ? `${guard.note}\n\n${text}` : text);
|
|
807
992
|
});
|
|
808
993
|
server.registerTool('ofw_get_unread_sent', {
|
|
809
|
-
description: 'List sent messages that have not been read by one or more recipients. Reads from local cache
|
|
810
|
-
annotations: { readOnlyHint:
|
|
994
|
+
description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
|
|
995
|
+
annotations: { readOnlyHint: false },
|
|
811
996
|
inputSchema: {
|
|
812
997
|
page: z.number().int().min(1).describe('Page (default 1)').optional(),
|
|
813
998
|
size: z.number().int().min(1).describe('Per page (default 50)').optional(),
|
|
999
|
+
autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
|
|
814
1000
|
},
|
|
815
1001
|
}, async (args) => {
|
|
816
1002
|
const page = args.page ?? 1;
|
|
817
1003
|
const size = args.size ?? 50;
|
|
818
1004
|
const cache = cacheProvider();
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1005
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
1006
|
+
client,
|
|
1007
|
+
cache,
|
|
1008
|
+
folders: ['sent'],
|
|
1009
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
1010
|
+
// The guard is about the CACHE being empty, not the verdict. "You have
|
|
1011
|
+
// no sent messages" is an absence claim a stale cache cannot support;
|
|
1012
|
+
// "all of them are read" is a verdict over messages we did see, and it is
|
|
1013
|
+
// labelled by `freshness` and `complete` as before.
|
|
1014
|
+
isEmpty: (v) => v.total === 0,
|
|
1015
|
+
read: async () => {
|
|
1016
|
+
const sent = await cache.listMessages({ folder: 'sent', page, size });
|
|
1017
|
+
const total = await cache.countMessages({ folder: 'sent' });
|
|
1018
|
+
// "Nobody has read it yet" is a present-tense claim drawn entirely from
|
|
1019
|
+
// cached view timestamps, which only move when a sync refreshes them —
|
|
1020
|
+
// so it needs the same age label as any other cached read.
|
|
1021
|
+
const freshness = await buildFreshness(cache, { source: 'cache', folders: ['sent'] });
|
|
1022
|
+
return { sent, total, freshness };
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
if (unverifiedEmpty) {
|
|
1026
|
+
return unverifiedEmptyResponse({
|
|
1027
|
+
what: 'sent messages in the local cache',
|
|
1028
|
+
freshness: value.freshness,
|
|
1029
|
+
refreshed,
|
|
1030
|
+
remedy: 'Call ofw_sync_messages(folders:["sent"]) and retry, or re-call with autoRefresh:true.',
|
|
1031
|
+
extra: { page, size },
|
|
829
1032
|
});
|
|
830
1033
|
}
|
|
1034
|
+
const { sent, total, freshness } = value;
|
|
1035
|
+
const fullSlice = page === 1 && sent.length === total;
|
|
1036
|
+
const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
|
|
831
1037
|
const unread = [];
|
|
832
1038
|
for (const msg of sent) {
|
|
833
1039
|
const unreadBy = msg.recipients.filter((r) => r.viewedAt === null).map((r) => r.name);
|
|
@@ -835,14 +1041,17 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
835
1041
|
unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
|
|
836
1042
|
}
|
|
837
1043
|
}
|
|
1044
|
+
const payload = { unread, scanned: sent.length, total, complete, freshness };
|
|
1045
|
+
if (!complete) {
|
|
1046
|
+
payload.completeNote = `This verdict covers the ${sent.length} of ${total} cached sent messages on this page${freshness.staleness === 'fresh' ? '' : `, from a cache that is "${freshness.staleness}"`}. It is not a statement about every message you have sent.`;
|
|
1047
|
+
}
|
|
838
1048
|
if (unread.length === 0) {
|
|
839
|
-
|
|
840
|
-
unread: [],
|
|
841
|
-
freshness,
|
|
842
|
-
message: 'All scanned sent messages had been read as of the timestamp in `freshness.asOf`. A recipient may have read a message since without the cache hearing about it.',
|
|
843
|
-
});
|
|
1049
|
+
payload.message = 'Every sent message scanned had been read as of the timestamp in `freshness.asOf`. A recipient may have read — or not read — a message since without the cache hearing about it.';
|
|
844
1050
|
}
|
|
845
|
-
|
|
1051
|
+
if (refreshed) {
|
|
1052
|
+
payload.autoRefreshed = true;
|
|
1053
|
+
}
|
|
1054
|
+
return jsonResponse(payload);
|
|
846
1055
|
});
|
|
847
1056
|
if (allowDrafts)
|
|
848
1057
|
server.registerTool('ofw_upload_attachment', {
|
|
@@ -1024,12 +1233,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1024
1233
|
return jsonResponse({ ...result, freshness });
|
|
1025
1234
|
});
|
|
1026
1235
|
server.registerTool('ofw_check_freshness', {
|
|
1027
|
-
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent"
|
|
1028
|
-
annotations: { readOnlyHint:
|
|
1236
|
+
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count. For each id it returns a LIVE lifecycle `state` — "draft" | "sent" | "received" | "deleted" | "unknown" — alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, because OFW draft timestamps do NOT change when a draft is edited in the web app. Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state. For draftKeys, or a full live draft inventory, use ofw_status.',
|
|
1237
|
+
annotations: { readOnlyHint: false },
|
|
1029
1238
|
inputSchema: {
|
|
1030
1239
|
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.').optional(),
|
|
1031
|
-
messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}).
|
|
1032
|
-
allowMarkRead: z.boolean().describe('Default false. Probing an id
|
|
1240
|
+
messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely — none of those can stamp the record. Anything else is skipped — see allowMarkRead.`).optional(),
|
|
1241
|
+
allowMarkRead: z.boolean().describe('Default false. Probing an id whose cached state cannot rule out an unread INBOX message requires fetching its detail, which marks it READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time — irreversible. Such ids are skipped (reason:"WOULD_MARK_READ") unless you set this to true. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this cannot raise.').optional(),
|
|
1033
1242
|
},
|
|
1034
1243
|
}, async (args) => {
|
|
1035
1244
|
const cache = cacheProvider();
|
|
@@ -1049,6 +1258,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1049
1258
|
requestsUsed++;
|
|
1050
1259
|
const data = parseLenient(FolderCountsSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders (ofw_check_freshness)' });
|
|
1051
1260
|
const sys = data.systemFolders ?? [];
|
|
1261
|
+
// Take the folder ids while we have them. Without this a call asking
|
|
1262
|
+
// about BOTH folders and ids, on a cache that has never synced, fetched
|
|
1263
|
+
// this exact endpoint twice — once here for the counts and again inside
|
|
1264
|
+
// probeIds' ensureFolderIdMap for the ids it already had in hand.
|
|
1265
|
+
await persistFolderIds(cache, sys);
|
|
1052
1266
|
for (const folder of wantFolders) {
|
|
1053
1267
|
const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
|
|
1054
1268
|
const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
|
|
@@ -1088,64 +1302,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1088
1302
|
});
|
|
1089
1303
|
}
|
|
1090
1304
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
// court-visible record. Refuse by default rather than quietly doing it.
|
|
1098
|
-
if (cachedDraft === null && !allowMarkRead) {
|
|
1099
|
-
items.push({
|
|
1100
|
-
id,
|
|
1101
|
-
skipped: true,
|
|
1102
|
-
reason: 'NOT_A_CACHED_DRAFT',
|
|
1103
|
-
note: 'Not in the drafts cache. Verifying it requires fetching its detail from OFW, which would mark an unread inbox message as READ on OurFamilyWizard. Pass allowMarkRead:true if that is acceptable.',
|
|
1104
|
-
});
|
|
1105
|
-
continue;
|
|
1106
|
-
}
|
|
1107
|
-
requestsUsed++;
|
|
1108
|
-
try {
|
|
1109
|
-
const server = await fetchServerDraft(client, id);
|
|
1110
|
-
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
1111
|
-
if (server === null) {
|
|
1112
|
-
items.push({
|
|
1113
|
-
id,
|
|
1114
|
-
existsOnServer: false,
|
|
1115
|
-
inSync: false,
|
|
1116
|
-
cacheRevision,
|
|
1117
|
-
serverRevision: null,
|
|
1118
|
-
note: cachedDraft === null
|
|
1119
|
-
? 'Not found on OurFamilyWizard.'
|
|
1120
|
-
: 'This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard — it was sent or deleted elsewhere. Do not describe it as still unsent.',
|
|
1121
|
-
});
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
const serverRevision = draftRevision(server);
|
|
1125
|
-
items.push({
|
|
1126
|
-
id,
|
|
1127
|
-
existsOnServer: true,
|
|
1128
|
-
cacheRevision,
|
|
1129
|
-
serverRevision,
|
|
1130
|
-
inSync: cacheRevision !== null && cacheRevision === serverRevision,
|
|
1131
|
-
...(cacheRevision === null
|
|
1132
|
-
? { note: 'Exists on OurFamilyWizard but is not in the local cache.' }
|
|
1133
|
-
: cacheRevision !== serverRevision
|
|
1134
|
-
? { note: 'Content differs from the cache — it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.' }
|
|
1135
|
-
: {}),
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
catch (e) {
|
|
1139
|
-
// A check that could not run must not read as "in sync".
|
|
1140
|
-
items.push({
|
|
1141
|
-
id,
|
|
1142
|
-
error: 'FRESHNESS_CHECK_FAILED',
|
|
1143
|
-
message: e.message,
|
|
1144
|
-
inSync: null,
|
|
1145
|
-
note: 'The freshness check itself failed, so nothing is confirmed either way.',
|
|
1146
|
-
});
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1305
|
+
// One folder-id resolve for the whole batch, and only when at least one id
|
|
1306
|
+
// is actually going to be probed — a call whose every id is refused for
|
|
1307
|
+
// mark-read reasons must cost nothing at all.
|
|
1308
|
+
const probed = await probeIds(client, cache, ids, { allowMarkRead });
|
|
1309
|
+
requestsUsed += probed.requests;
|
|
1310
|
+
const items = probed.items;
|
|
1149
1311
|
const payload = {
|
|
1150
1312
|
checkedAt: new Date().toISOString(),
|
|
1151
1313
|
requestsUsed,
|
|
@@ -1157,6 +1319,156 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1157
1319
|
}
|
|
1158
1320
|
return jsonResponse(payload);
|
|
1159
1321
|
});
|
|
1322
|
+
server.registerTool('ofw_status', {
|
|
1323
|
+
description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages — never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" — it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
|
|
1324
|
+
annotations: { readOnlyHint: false },
|
|
1325
|
+
inputSchema: {
|
|
1326
|
+
ids: z.array(z.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
|
|
1327
|
+
draftKeys: z.array(z.string()).describe('Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.').optional(),
|
|
1328
|
+
includeDraftInventory: z.boolean().describe('Return the full current draft list, verified against OurFamilyWizard first. Defaults to TRUE when neither ids nor draftKeys is given (so a bare ofw_status() is a complete status snapshot), otherwise false.').optional(),
|
|
1329
|
+
allowMarkRead: z.boolean().describe('Default false. An id whose cached state cannot rule out an unread INBOX message can only be probed by fetching its detail, which marks it READ on OurFamilyWizard — irreversible and co-parent-visible. Those are skipped unless this is true. Cached drafts, sent messages and already-read messages are always probed. Capped by OFW_ALLOW_MARK_READ.').optional(),
|
|
1330
|
+
},
|
|
1331
|
+
}, async (args) => {
|
|
1332
|
+
const cache = cacheProvider();
|
|
1333
|
+
const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
|
|
1334
|
+
const requestedIds = args.ids ?? [];
|
|
1335
|
+
const requestedKeys = args.draftKeys ?? [];
|
|
1336
|
+
// A bare ofw_status() is meant to be the "here is where everything stands"
|
|
1337
|
+
// call, so it defaults to the inventory. Asking about specific ids does not
|
|
1338
|
+
// silently spend a drafts sync you did not ask for.
|
|
1339
|
+
const wantInventory = args.includeDraftInventory
|
|
1340
|
+
?? (requestedIds.length === 0 && requestedKeys.length === 0);
|
|
1341
|
+
const allTargets = [
|
|
1342
|
+
...requestedIds.map((id) => ({ kind: 'id', id })),
|
|
1343
|
+
...requestedKeys.map((draftKey) => ({ kind: 'draftKey', draftKey })),
|
|
1344
|
+
];
|
|
1345
|
+
const targets = allTargets.slice(0, MAX_FRESHNESS_IDS);
|
|
1346
|
+
const truncated = allTargets.length - targets.length;
|
|
1347
|
+
// Nothing asked for, nothing checked — so there is nothing to be complete
|
|
1348
|
+
// ABOUT. Returning `complete: true` here would be a reassuring-looking
|
|
1349
|
+
// payload that verified precisely nothing, which is the failure mode this
|
|
1350
|
+
// whole tool exists to remove.
|
|
1351
|
+
if (!wantInventory && targets.length === 0) {
|
|
1352
|
+
return jsonErrorResponse({
|
|
1353
|
+
result: 'NOTHING_REQUESTED',
|
|
1354
|
+
reason: 'ofw_status was called with includeDraftInventory:false and no ids or draftKeys, so nothing was checked.',
|
|
1355
|
+
remedy: 'Call ofw_status() with no arguments for the full draft inventory, or pass ids / draftKeys.',
|
|
1356
|
+
complete: false,
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
let probeRequests = 0;
|
|
1360
|
+
const incomplete = [];
|
|
1361
|
+
// ── Draft inventory ────────────────────────────────────────────────────
|
|
1362
|
+
let drafts;
|
|
1363
|
+
let inventoryComplete = true;
|
|
1364
|
+
let inventoryFreshness;
|
|
1365
|
+
if (wantInventory) {
|
|
1366
|
+
const sync = await syncAll(client, {
|
|
1367
|
+
folders: ['drafts'],
|
|
1368
|
+
maxRequests: getSyncMaxRequests(),
|
|
1369
|
+
}, cache);
|
|
1370
|
+
// `refreshed` is the only honest signal that the walk actually diffed
|
|
1371
|
+
// against OFW. A budget-paused walk applies nothing and returns no count
|
|
1372
|
+
// — reporting its inventory as complete would be the same lie as
|
|
1373
|
+
// `drafts: 0` from a deferred sync.
|
|
1374
|
+
inventoryComplete = sync.refreshed.includes('drafts');
|
|
1375
|
+
const { freshness, cacheStatus, serverConfirmed } = await draftsFreshness(cache);
|
|
1376
|
+
inventoryFreshness = freshness;
|
|
1377
|
+
if (!serverConfirmed)
|
|
1378
|
+
inventoryComplete = false;
|
|
1379
|
+
const total = await cache.countDrafts();
|
|
1380
|
+
const rows = await cache.listDrafts({ page: 1, size: Math.max(total, 1) });
|
|
1381
|
+
const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
|
|
1382
|
+
drafts = rows.map((d) => ({
|
|
1383
|
+
id: d.id,
|
|
1384
|
+
draftKey: keyById.get(d.id) ?? null,
|
|
1385
|
+
subject: d.subject,
|
|
1386
|
+
revision: draftRevision(d),
|
|
1387
|
+
modifiedAt: d.modifiedAt,
|
|
1388
|
+
recipients: d.recipients,
|
|
1389
|
+
replyToId: d.replyToId,
|
|
1390
|
+
cacheStatus,
|
|
1391
|
+
}));
|
|
1392
|
+
if (!inventoryComplete) {
|
|
1393
|
+
incomplete.push('the drafts folder was not fully verified against OurFamilyWizard on this call (the request budget paused the walk), so this inventory may be missing or misreporting drafts');
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
// ── Per-id / per-key lifecycle ─────────────────────────────────────────
|
|
1397
|
+
const requested = [];
|
|
1398
|
+
if (targets.length > 0) {
|
|
1399
|
+
// Resolve keys to ids first so a key and a bare id naming the SAME
|
|
1400
|
+
// message are probed once, not twice.
|
|
1401
|
+
const resolved = new Map();
|
|
1402
|
+
for (const t of targets) {
|
|
1403
|
+
if (t.kind === 'draftKey' && !resolved.has(t.draftKey)) {
|
|
1404
|
+
resolved.set(t.draftKey, await resolveDraftKey(cache, t.draftKey));
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const toProbe = new Set();
|
|
1408
|
+
for (const t of targets) {
|
|
1409
|
+
if (t.kind === 'id')
|
|
1410
|
+
toProbe.add(t.id);
|
|
1411
|
+
else {
|
|
1412
|
+
const chain = resolved.get(t.draftKey);
|
|
1413
|
+
if (chain !== null && chain !== undefined)
|
|
1414
|
+
toProbe.add(chain.currentId);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
const probed = await probeIds(client, cache, [...toProbe], { allowMarkRead });
|
|
1418
|
+
probeRequests += probed.requests;
|
|
1419
|
+
const probes = new Map(probed.items.map((item) => [item.id, item]));
|
|
1420
|
+
for (const t of targets) {
|
|
1421
|
+
if (t.kind === 'id') {
|
|
1422
|
+
requested.push(decorate(probes.get(t.id)));
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
const chain = resolved.get(t.draftKey);
|
|
1426
|
+
if (chain === null || chain === undefined) {
|
|
1427
|
+
requested.push({
|
|
1428
|
+
draftKey: t.draftKey,
|
|
1429
|
+
state: 'unknown',
|
|
1430
|
+
error: 'UNKNOWN_DRAFT_KEY',
|
|
1431
|
+
note: 'This draftKey has never been recorded in the local cache, so it cannot be resolved to a message id. Draft keys are minted by ofw_save_draft; a cache rebuilt or opened on another machine will not know an older key.',
|
|
1432
|
+
});
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
requested.push({
|
|
1436
|
+
draftKey: t.draftKey,
|
|
1437
|
+
currentId: chain.currentId,
|
|
1438
|
+
previousIds: chain.ids.slice(0, -1),
|
|
1439
|
+
...decorate(probes.get(chain.currentId)),
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
for (const entry of requested) {
|
|
1443
|
+
if (entry.skipped === true || entry.error !== undefined || entry.state === 'unknown') {
|
|
1444
|
+
incomplete.push(`id/key ${String(entry.draftKey ?? entry.id)} could not be resolved to a confirmed live state`);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
if (truncated > 0) {
|
|
1449
|
+
incomplete.push(`${truncated} of ${allTargets.length} requested ids/draftKeys were not probed (per-call cap of ${MAX_FRESHNESS_IDS})`);
|
|
1450
|
+
}
|
|
1451
|
+
const complete = incomplete.length === 0;
|
|
1452
|
+
return jsonResponse({
|
|
1453
|
+
checkedAt: new Date().toISOString(),
|
|
1454
|
+
probeRequests,
|
|
1455
|
+
...(drafts !== undefined ? { drafts, draftCount: drafts.length, draftInventoryComplete: inventoryComplete } : {}),
|
|
1456
|
+
...(requested.length > 0 ? { requested } : {}),
|
|
1457
|
+
complete,
|
|
1458
|
+
...(complete
|
|
1459
|
+
? {}
|
|
1460
|
+
: { incompleteReasons: incomplete, note: 'complete:false — this snapshot is NOT a verified statement of current state. Do not report a draft count or say whether something was sent from it; resolve the reasons above (usually by calling ofw_sync_messages, or re-calling with allowMarkRead:true) and ask again.' }),
|
|
1461
|
+
...(inventoryFreshness !== undefined ? { freshness: inventoryFreshness } : {}),
|
|
1462
|
+
});
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Add the derived fields a status entry wants on top of a raw lifecycle probe.
|
|
1467
|
+
* `sentMessageId` names the id the message ended up as once sent — the answer to
|
|
1468
|
+
* "the draft I was editing went where?" — and only exists for a `sent` verdict.
|
|
1469
|
+
*/
|
|
1470
|
+
function decorate(item) {
|
|
1471
|
+
return item.state === 'sent' ? { ...item, sentMessageId: item.id } : { ...item };
|
|
1160
1472
|
}
|
|
1161
1473
|
// OFW's bulk-delete endpoint takes a multipart form with `messageIds`.
|
|
1162
1474
|
// Used by both ofw_delete_draft and ofw_send_message (draft cleanup).
|