Skip to content

Commit 4371e0c

Browse files
committed
feat: Foundry sync diagnostics, error recovery & validation report (Sprint F-QoL)
Server-side: - ValidationReport type with BuildValidationReport() on SystemManifest - Analyzes categories, fields, presets, Foundry compatibility, warnings - SystemValidationReport templ component shows capability badges after upload Foundry module: - api-client.mjs: health metrics (success/error counts, uptime, reconnects), structured error log, retry queue for failed writes (auto-retries on reconnect) - sync-dashboard.mjs: Status tab now shows diagnostics grid, error log, field mapping debug info (adapter type, system ID, character type) - sync-dashboard.hbs: new diagnostics, error log, and field mapping sections - chronicle-sync.css: diagnostics grid and error log styles 3 new tests for ValidationReport. Dagger Heart added to future systems list. https://claude.ai/code/session_01PeB1HsjEYNPSY2iqa7sqqR
1 parent be4d935 commit 4371e0c

9 files changed

Lines changed: 679 additions & 12 deletions

File tree

.ai/status.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-12 -- **Sprint F-4.5: Generic System Adapter & Dynamic Matching.**
11+
2026-03-12 -- **Sprint F-QoL: Foundry Sync Diagnostics & Error Handling.**
12+
13+
38. **Sprint F-QoL: Foundry Sync Diagnostics (DONE).**
14+
- **Validation report** — New `ValidationReport` type and `BuildValidationReport()` on `SystemManifest`. Analyzes categories, fields, presets, Foundry compatibility, mapped/writable fields, and generates warnings. Shown in custom system section after upload.
15+
- **Template update**`SystemValidationReport` templ component renders capability badges (categories, fields, presets, character fields), Foundry compatibility status, field mapping summary, and warnings.
16+
- **API client health metrics**`api-client.mjs` now tracks REST success/error counts, reconnect attempts, connection uptime, last success/error timestamps. New `_errorLog` array with structured error entries (method, path, status, message). `getUptimePercent()` computes session uptime.
17+
- **Retry queue** — New `queueForRetry()` method for failed write operations. `processRetryQueue()` runs on WebSocket reconnect, retrying up to 3 times with structured logging. Queue capped at 50 entries.
18+
- **Dashboard diagnostics** — Status tab now shows: 4-column diagnostics grid (uptime%, API OK, API errors, reconnects), last success/error timestamps, pending retry count, field mapping debug info (adapter type, system ID, character type slug), and separate error log section.
19+
- **Dashboard CSS** — Added `.diagnostics-grid`, `.diagnostics-detail`, `.error-value`, `.error-text`, `.error-log` styles.
20+
- **Tests** — 3 new tests: `BuildValidationReport_FullSystem`, `BuildValidationReport_MinimalSystem`, `BuildValidationReport_NoFoundryPaths`. All pass.
21+
- **Dagger Heart** added to deferred systems list alongside Draw Steel.
1222

1323
37. **Sprint F-4.5: Generic System Adapter (DONE).**
1424
- **Manifest schema** — Added `foundry_system_id` to `SystemManifest`, `foundry_path` and `foundry_writable` to `FieldDef`. `IsFoundryWritable()` helper defaults to true when nil. New `CharacterFieldsForAPI()` builds API response with field annotations.

.ai/todo.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ _WASM-sandboxed backend logic via Extism/wazero. See ADR-021._
279279
- [x] **Sprint R-3: Write Host Functions** — 6 write host functions (update_entity_fields, create_event, set_entity_tags, get_entity_tags, create_relation, send_message). 5 new capabilities. 4 write adapters. Plugin-to-plugin async messaging. 10 new tests (48 total).
280280
- [x] **Sprint R-4: Plugin SDK & Developer Tools** — Example WASM plugins (Rust auto-tagger, Go session-logger). Go SDK with MockHost test harness (9 tests). Plugin development guide. 7 new manifest tests. **Phase R complete.**
281281

282-
### Phase F: Foundry Sync Enhancements & Character Integration ← CURRENT (F-QoL next)
282+
### Phase F: Foundry Sync Enhancements & Character Integration ← CURRENT (F-5 next)
283283

284284
_Improve Foundry VTT sync fidelity. Add system-aware character sheet sync. Build toward inventory/NPC features._
285285

@@ -288,7 +288,7 @@ _Improve Foundry VTT sync fidelity. Add system-aware character sheet sync. Build
288288
- [x] **Sprint F-3: System Detection & Character Field Templates** — Expanded dnd5e character preset (15 fields: class, level, race, alignment + 6 ability scores + HP/AC/speed/proficiency). Added pf2e character preset (15 fields: class, level, ancestry, heritage + 6 ability mods + HP/AC/perception/speed). `CharacterPreset()` helper on `SystemManifest`. New `GET /api/v1/campaigns/:id/systems` endpoint returns available systems with enabled flag. Foundry module: `syncCharacters` + `detectedSystem` settings, `SYSTEM_MAP` table, `_detectSystem()` on start, `getMatchedSystem()` accessor. Dashboard Status tab shows system match info and character sync availability.
289289
- [x] **Sprint F-4: Actor ↔ Entity Sync**`actor-sync.mjs` with bidirectional Actor ↔ entity sync. System adapters: `dnd5e-adapter.mjs` (15 fields), `pf2e-adapter.mjs` (HP/name back only). Dashboard Characters tab with Push button. Registered in module.mjs. TESTING.md updated. **Note: adapters and SYSTEM_MAP are hardcoded — see F-4.5.**
290290
- [x] **Sprint F-4.5: Generic System Adapter & Dynamic Matching** — Added `foundry_system_id` to manifest, `foundry_path`/`foundry_writable` to FieldDef. New `GET /systems/:id/character-fields` API. `_detectSystem()` now API-driven (matches by `foundry_system_id`). New `generic-adapter.mjs` auto-generates field mappings from API. dnd5e (15 fields), pf2e (15 fields, most read-only), drawsteel annotated. actor-sync.mjs falls back to generic adapter. 7 new tests.
291-
- [ ] **Sprint F-QoL: Foundry Sync Diagnostics & Error Handling**Validation report UI on system upload (field count, presets, Foundry compatibility). System preview before enabling. Foundry sync health dashboard (connection uptime, last sync timestamps, error log, retry buttons). Graceful error recovery (queue pending changes on disconnect, retry on reconnect). Field mapping debug view in Foundry dashboard.
291+
- [x] **Sprint F-QoL: Foundry Sync Diagnostics & Error Handling**`ValidationReport` type with `BuildValidationReport()` analyzing categories, fields, presets, Foundry compatibility, warnings. Templ component shows capability badges + warnings after upload. API client health metrics (success/error counts, uptime, reconnect attempts). Structured error log. Retry queue for failed writes (processes on reconnect, max 3 retries). Dashboard Status tab: diagnostics grid, error log, field mapping debug info. 3 new tests.
292292
- [ ] **Sprint F-5: NPC Viewer / Hall (Plugin + Widget + Foundry Sync)** — Full NPC plugin at `internal/plugins/npcs/` with handler/service/repo/templates. Campaign route `/campaigns/:id/npcs` — gallery/grid of revealed NPCs (non-private character entities). Portrait, name, description, location, faction. Filters by location/organization/relation. "Reveal" = DM toggles visibility. `npc_gallery` layout block type for entity pages and dashboards. Foundry sync: NPC visibility changes sync bidirectionally — DM reveals/hides NPC in Foundry → Chronicle entity visibility updates and vice versa via actor-sync. Long-term: NPC relationship map (filtered relation graph).
293293
- [ ] **Sprint F-6: Armory / Inventory System** — Items as entities with game-mechanic fields (weight, cost, rarity, damage, properties). Character "Inventory" tab/block via entity relations. Relation metadata: equipped, quantity, attunement. System-specific item templates (dnd5e ≠ pf2e). Foundry sync: Actor inventory ↔ Chronicle inventory relations. "Armory" campaign page showing all catalogued items.
294294
- [ ] **Sprint F-7: Shop / Marketplace Enhancement** — Transaction logging (who bought what, when). Currency tracking per character. Stock management (auto-deplete on purchase). Foundry: purchase from shop window → update character inventory on both sides.
@@ -308,7 +308,8 @@ is truly modular and self-service._
308308
### Deferred to Phase S+ (or community contributions)
309309

310310
- [ ] **Module Builder UI** — Guided wizard that helps users create custom game system modules through the web UI. Step-by-step: name/metadata → define categories → define fields per category → paste/upload reference data → preview tooltips → export as module directory. Eliminates need to hand-write manifest.json + data files.
311-
- [ ] Draw Steel module
311+
- [ ] Draw Steel module (system data + Foundry adapter)
312+
- [ ] Dagger Heart module (system data + Foundry adapter)
312313
- [ ] Whiteboards / freeform canvas (Tldraw/Excalidraw)
313314
- [ ] Offline mode / service worker caching
314315
- [ ] Collaborative editing presence indicators

foundry-module/scripts/api-client.mjs

Lines changed: 234 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
* Chronicle Sync - API Client
33
*
44
* Handles both REST API calls and WebSocket connection to the Chronicle server.
5-
* Provides auto-reconnect with exponential backoff for WebSocket, and a
6-
* message queue for offline buffering.
5+
* Provides auto-reconnect with exponential backoff for WebSocket, a message
6+
* queue for offline buffering, health metrics, error logging, and a retry
7+
* queue for failed write operations.
78
*/
89

910
import { getSetting } from './settings.mjs';
@@ -37,12 +38,45 @@ export class ChronicleAPI {
3738

3839
/** @type {Set<Function>} Callbacks invoked when connection state changes. */
3940
this._stateChangeCallbacks = new Set();
41+
42+
// --- Health metrics (F-QoL) ---
43+
44+
/** @type {object} Connection and sync health metrics. */
45+
this.health = {
46+
/** Total successful REST API calls this session. */
47+
restSuccessCount: 0,
48+
/** Total failed REST API calls this session. */
49+
restErrorCount: 0,
50+
/** Total WebSocket reconnection attempts this session. */
51+
reconnectAttempts: 0,
52+
/** Timestamp (ms) of when the current connection was established. */
53+
connectedSince: null,
54+
/** Total time connected (ms), excluding current connection. */
55+
totalConnectedMs: 0,
56+
/** Timestamp (ms) of last successful REST call. */
57+
lastRestSuccess: null,
58+
/** Timestamp (ms) of last REST error. */
59+
lastRestError: null,
60+
};
61+
62+
/** @type {Array<{time: number, level: string, method: string, path: string, status: number|null, message: string}>} */
63+
this._errorLog = [];
64+
65+
/** @type {number} Maximum error log entries. */
66+
this._maxErrorLogEntries = 50;
67+
68+
/** @type {Array<{method: string, path: string, body: string|null, retries: number, maxRetries: number}>} */
69+
this._retryQueue = [];
70+
71+
/** @type {boolean} Whether we're currently processing the retry queue. */
72+
this._retryProcessing = false;
4073
}
4174

4275
// --- REST API ---
4376

4477
/**
4578
* Make an authenticated REST API call to Chronicle.
79+
* Tracks health metrics and logs errors.
4680
* @param {string} path - API path (e.g., '/entities').
4781
* @param {object} [options] - Fetch options.
4882
* @returns {Promise<any>} Parsed JSON response.
@@ -53,23 +87,40 @@ export class ChronicleAPI {
5387
const campaignId = getSetting('campaignId');
5488

5589
const url = `${baseUrl}/api/v1/campaigns/${campaignId}${path}`;
90+
const method = options.method || 'GET';
5691

5792
const headers = {
5893
'Authorization': `Bearer ${apiKey}`,
5994
'Content-Type': 'application/json',
6095
...options.headers,
6196
};
6297

63-
const response = await fetch(url, {
64-
...options,
65-
headers,
66-
});
98+
let response;
99+
try {
100+
response = await fetch(url, {
101+
...options,
102+
headers,
103+
});
104+
} catch (err) {
105+
// Network error (no response at all).
106+
this.health.restErrorCount++;
107+
this.health.lastRestError = Date.now();
108+
this._logError('error', method, path, null, err.message || 'Network error');
109+
throw err;
110+
}
67111

68112
if (!response.ok) {
69113
const errorBody = await response.text();
114+
this.health.restErrorCount++;
115+
this.health.lastRestError = Date.now();
116+
this._logError('error', method, path, response.status, errorBody);
70117
throw new Error(`Chronicle API error ${response.status}: ${errorBody}`);
71118
}
72119

120+
// Success.
121+
this.health.restSuccessCount++;
122+
this.health.lastRestSuccess = Date.now();
123+
73124
// Handle 204 No Content.
74125
if (response.status === 204) return null;
75126

@@ -157,12 +208,163 @@ export class ChronicleAPI {
157208
);
158209

159210
if (!response.ok) {
211+
this.health.restErrorCount++;
212+
this.health.lastRestError = Date.now();
213+
this._logError('error', 'POST', '/media', response.status, 'Media upload failed');
160214
throw new Error(`Media upload failed: ${response.status}`);
161215
}
162216

217+
this.health.restSuccessCount++;
218+
this.health.lastRestSuccess = Date.now();
163219
return response.json();
164220
}
165221

222+
// --- Retry queue (F-QoL) ---
223+
224+
/**
225+
* Queue a failed write operation for retry on reconnect.
226+
* Only queues POST/PUT/PATCH/DELETE — never retries GETs.
227+
* @param {string} method - HTTP method.
228+
* @param {string} path - API path.
229+
* @param {object|null} body - Request body (will be JSON-stringified).
230+
* @param {number} [maxRetries=3] - Maximum retry attempts.
231+
*/
232+
queueForRetry(method, path, body = null, maxRetries = 3) {
233+
if (method === 'GET') return; // Never retry reads.
234+
if (this._retryQueue.length >= 50) {
235+
// Cap queue size to prevent memory issues.
236+
this._logError('warn', method, path, null, 'Retry queue full, dropping operation');
237+
return;
238+
}
239+
this._retryQueue.push({
240+
method,
241+
path,
242+
body: body ? JSON.stringify(body) : null,
243+
retries: 0,
244+
maxRetries,
245+
});
246+
console.log(`Chronicle: Queued ${method} ${path} for retry (${this._retryQueue.length} pending)`);
247+
}
248+
249+
/**
250+
* Process the retry queue. Called after reconnection.
251+
* @returns {Promise<{success: number, failed: number}>}
252+
*/
253+
async processRetryQueue() {
254+
if (this._retryProcessing || this._retryQueue.length === 0) {
255+
return { success: 0, failed: 0 };
256+
}
257+
258+
this._retryProcessing = true;
259+
let success = 0;
260+
let failed = 0;
261+
262+
// Process a snapshot of the queue.
263+
const pending = [...this._retryQueue];
264+
this._retryQueue = [];
265+
266+
for (const item of pending) {
267+
try {
268+
await this.fetch(item.path, {
269+
method: item.method,
270+
body: item.body,
271+
});
272+
success++;
273+
} catch (err) {
274+
item.retries++;
275+
if (item.retries < item.maxRetries) {
276+
this._retryQueue.push(item);
277+
console.warn(`Chronicle: Retry ${item.retries}/${item.maxRetries} failed for ${item.method} ${item.path}`);
278+
} else {
279+
failed++;
280+
this._logError('error', item.method, item.path, null,
281+
`Permanently failed after ${item.maxRetries} retries: ${err.message}`);
282+
}
283+
}
284+
}
285+
286+
this._retryProcessing = false;
287+
288+
if (success > 0 || failed > 0) {
289+
console.log(`Chronicle: Retry queue processed — ${success} succeeded, ${failed} permanently failed, ${this._retryQueue.length} remaining`);
290+
}
291+
292+
return { success, failed };
293+
}
294+
295+
/**
296+
* Get the number of pending retry operations.
297+
* @returns {number}
298+
*/
299+
getRetryQueueSize() {
300+
return this._retryQueue.length;
301+
}
302+
303+
// --- Health & error log (F-QoL) ---
304+
305+
/**
306+
* Log a REST API error for dashboard display.
307+
* @param {string} level - 'error' or 'warn'.
308+
* @param {string} method - HTTP method.
309+
* @param {string} path - API path.
310+
* @param {number|null} status - HTTP status code.
311+
* @param {string} message - Error message.
312+
* @private
313+
*/
314+
_logError(level, method, path, status, message) {
315+
this._errorLog.unshift({
316+
time: Date.now(),
317+
timeFormatted: new Date().toLocaleTimeString(),
318+
level,
319+
method,
320+
path,
321+
status,
322+
message: message.substring(0, 200), // Truncate long error bodies.
323+
});
324+
if (this._errorLog.length > this._maxErrorLogEntries) {
325+
this._errorLog.length = this._maxErrorLogEntries;
326+
}
327+
}
328+
329+
/**
330+
* Get the error log entries for dashboard display.
331+
* @returns {Array<{time: number, level: string, method: string, path: string, status: number|null, message: string}>}
332+
*/
333+
getErrorLog() {
334+
return this._errorLog;
335+
}
336+
337+
/**
338+
* Clear the error log.
339+
*/
340+
clearErrorLog() {
341+
this._errorLog = [];
342+
}
343+
344+
/**
345+
* Get connection uptime percentage for the current session.
346+
* @returns {number} 0-100 percentage.
347+
*/
348+
getUptimePercent() {
349+
const sessionStart = this.health.totalConnectedMs;
350+
let connectedMs = sessionStart;
351+
352+
if (this.health.connectedSince) {
353+
connectedMs += Date.now() - this.health.connectedSince;
354+
}
355+
356+
// Approximate session duration from first success or first error.
357+
const firstActivity = Math.min(
358+
this.health.lastRestSuccess || Date.now(),
359+
this.health.lastRestError || Date.now(),
360+
this.health.connectedSince || Date.now()
361+
);
362+
const sessionDuration = Date.now() - firstActivity;
363+
364+
if (sessionDuration <= 0) return 100;
365+
return Math.min(100, Math.round((connectedMs / sessionDuration) * 100));
366+
}
367+
166368
// --- WebSocket ---
167369

168370
/**
@@ -181,6 +383,11 @@ export class ChronicleAPI {
181383
this._shouldConnect = false;
182384
this._clearReconnect();
183385
if (this._ws) {
386+
// Track connected time before disconnecting.
387+
if (this.health.connectedSince) {
388+
this.health.totalConnectedMs += Date.now() - this.health.connectedSince;
389+
this.health.connectedSince = null;
390+
}
184391
this._ws.close(1000, 'Client disconnect');
185392
this._ws = null;
186393
}
@@ -243,28 +450,45 @@ export class ChronicleAPI {
243450
this._ws = new WebSocket(wsUrl);
244451
} catch (err) {
245452
console.error('Chronicle: WebSocket creation failed', err);
453+
this._logError('error', 'WS', '/ws', null, err.message || 'WebSocket creation failed');
246454
this._scheduleReconnect();
247455
return;
248456
}
249457

250-
this._ws.onopen = () => {
458+
this._ws.onopen = async () => {
251459
console.log('Chronicle: WebSocket connected');
252460
this._setState('connected');
253461
this._reconnectDelay = 1000; // Reset backoff.
462+
this.health.connectedSince = Date.now();
254463

255464
// Flush queued messages.
256465
while (this._messageQueue.length > 0) {
257466
const msg = this._messageQueue.shift();
258467
this._ws.send(JSON.stringify(msg));
259468
}
260469

470+
// Process retry queue on reconnect.
471+
if (this._retryQueue.length > 0) {
472+
const result = await this.processRetryQueue();
473+
if (result.success > 0 || result.failed > 0) {
474+
this._emit('sync.retryComplete', { payload: result });
475+
}
476+
}
477+
261478
// Notify listeners.
262479
this._emit('sync.status', { status: 'connected' });
263480
};
264481

265482
this._ws.onclose = (event) => {
266483
console.log(`Chronicle: WebSocket closed (code=${event.code})`);
267484
this._ws = null;
485+
486+
// Track connected time.
487+
if (this.health.connectedSince) {
488+
this.health.totalConnectedMs += Date.now() - this.health.connectedSince;
489+
this.health.connectedSince = null;
490+
}
491+
268492
this._setState('disconnected');
269493

270494
if (this._shouldConnect && event.code !== 1000) {
@@ -274,6 +498,7 @@ export class ChronicleAPI {
274498

275499
this._ws.onerror = (error) => {
276500
console.error('Chronicle: WebSocket error', error);
501+
this._logError('error', 'WS', '/ws', null, 'WebSocket connection error');
277502
// onclose will fire after onerror, triggering reconnect.
278503
};
279504

@@ -294,9 +519,10 @@ export class ChronicleAPI {
294519
_scheduleReconnect() {
295520
this._clearReconnect();
296521
this._setState('reconnecting');
522+
this.health.reconnectAttempts++;
297523

298524
const delay = Math.min(this._reconnectDelay, 30000); // Cap at 30s.
299-
console.log(`Chronicle: Reconnecting in ${delay}ms`);
525+
console.log(`Chronicle: Reconnecting in ${delay}ms (attempt ${this.health.reconnectAttempts})`);
300526

301527
this._reconnectTimer = setTimeout(() => {
302528
this._reconnectTimer = null;

0 commit comments

Comments
 (0)