@vue-skuilder/db 0.1.35 → 0.1.38
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/dist/core/index.d.cts +46 -0
- package/dist/core/index.d.ts +46 -0
- package/dist/core/index.js +165 -14
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +165 -14
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +165 -14
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +165 -14
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +165 -14
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +165 -14
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +32 -2
- package/dist/index.d.ts +32 -2
- package/dist/index.js +231 -27
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +231 -27
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/navigators/Pipeline.ts +5 -2
- package/src/core/navigators/PipelineDebugger.ts +238 -8
- package/src/impl/common/BaseUserDB.ts +10 -2
- package/src/study/SessionController.ts +74 -11
- package/src/study/SessionDebugger.ts +8 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.1.
|
|
7
|
+
"version": "0.1.38",
|
|
8
8
|
"description": "Database layer for vue-skuilder",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"module": "dist/index.mjs",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@nilock2/pouchdb-authentication": "^1.0.2",
|
|
51
|
-
"@vue-skuilder/common": "0.1.
|
|
51
|
+
"@vue-skuilder/common": "0.1.38",
|
|
52
52
|
"cross-fetch": "^4.1.0",
|
|
53
53
|
"moment": "^2.29.4",
|
|
54
54
|
"pouchdb": "^9.0.0",
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
"vite": "^8.0.0",
|
|
63
63
|
"vitest": "^4.1.0"
|
|
64
64
|
},
|
|
65
|
-
"stableVersion": "0.1.
|
|
65
|
+
"stableVersion": "0.1.38"
|
|
66
66
|
}
|
|
@@ -524,8 +524,11 @@ export class Pipeline extends ContentNavigator {
|
|
|
524
524
|
// Capture run for debug API
|
|
525
525
|
try {
|
|
526
526
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => undefined);
|
|
527
|
-
//
|
|
528
|
-
//
|
|
527
|
+
// Pass the full post-filter sorted array; buildRunReport retains all
|
|
528
|
+
// selected cards plus the top-N highest-scoring near-misses and
|
|
529
|
+
// summarizes the discarded tail (see DISCARDED_KEEP_TOP). This keeps
|
|
530
|
+
// showCard() useful for "why didn't this rank?" inspection without
|
|
531
|
+
// pinning hundreds of low-score candidates' provenance per run.
|
|
529
532
|
// `cards` is the post-filter, post-hints, sorted array.
|
|
530
533
|
const report = buildRunReport(
|
|
531
534
|
this.course?.getCourseID() || 'unknown',
|
|
@@ -92,7 +92,19 @@ export interface PipelineRunReport {
|
|
|
92
92
|
reviewsSelected: number;
|
|
93
93
|
newSelected: number;
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Card data for inspection.
|
|
97
|
+
*
|
|
98
|
+
* To keep the in-memory ring buffer small, this contains:
|
|
99
|
+
* - All selected cards (the actual session output).
|
|
100
|
+
* - The top-N highest-scoring non-selected cards (see DISCARDED_KEEP_TOP),
|
|
101
|
+
* useful for understanding "near misses" and filter behavior.
|
|
102
|
+
*
|
|
103
|
+
* The remaining low-score tail of the candidate pool (mostly ELO-window
|
|
104
|
+
* pull remnants that filters scored down) is summarized in `discardedTail`
|
|
105
|
+
* rather than retained verbatim — each retained card carries a multi-KB
|
|
106
|
+
* provenance trail, and the tail is typically hundreds of cards per run.
|
|
107
|
+
*/
|
|
96
108
|
cards: Array<{
|
|
97
109
|
cardId: string;
|
|
98
110
|
courseId: string;
|
|
@@ -105,6 +117,26 @@ export interface PipelineRunReport {
|
|
|
105
117
|
tags?: string[];
|
|
106
118
|
selected: boolean;
|
|
107
119
|
}>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Summary of the discarded tail of the candidate pool — cards that were
|
|
123
|
+
* generated and scored but neither selected nor retained in `cards`.
|
|
124
|
+
*
|
|
125
|
+
* Provides breadcrumbs for "where did card X go?" investigations without
|
|
126
|
+
* the memory cost of keeping every dropped candidate's provenance.
|
|
127
|
+
*
|
|
128
|
+
* Future: may carry a bloom filter of discarded cardIds so callers can
|
|
129
|
+
* ask "was X in this run's discard tail?" with bounded memory cost.
|
|
130
|
+
*/
|
|
131
|
+
discardedTail?: {
|
|
132
|
+
count: number;
|
|
133
|
+
/** [min, max] finalScore across the discarded tail. */
|
|
134
|
+
scoreRange?: [number, number];
|
|
135
|
+
/** [min, max] cardElo across the discarded tail (where parseable). */
|
|
136
|
+
eloRange?: [number, number];
|
|
137
|
+
/** Human-readable note for console display. */
|
|
138
|
+
note: string;
|
|
139
|
+
};
|
|
108
140
|
}
|
|
109
141
|
|
|
110
142
|
/**
|
|
@@ -113,6 +145,30 @@ export interface PipelineRunReport {
|
|
|
113
145
|
const MAX_RUNS = 10;
|
|
114
146
|
const runHistory: PipelineRunReport[] = [];
|
|
115
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Cap on non-selected ("discarded") cards retained per run report.
|
|
150
|
+
*
|
|
151
|
+
* Pipeline candidate pools are typically hundreds of cards (ELO window pull
|
|
152
|
+
* + generators), but most are scored down by filters and never selected.
|
|
153
|
+
* Their provenance trails are the dominant text-memory cost in the ring
|
|
154
|
+
* buffer (each entry ~1–2 KB). Retaining only the top-N "near misses"
|
|
155
|
+
* preserves debugging value while collapsing memory roughly an order of
|
|
156
|
+
* magnitude or more.
|
|
157
|
+
*/
|
|
158
|
+
const DISCARDED_KEEP_TOP = 25;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Programmatic clear of pipeline run history.
|
|
162
|
+
*
|
|
163
|
+
* Called by session-lifecycle hooks (session end, course switch, logout)
|
|
164
|
+
* to release retained provenance/tag arrays once they're no longer useful
|
|
165
|
+
* for in-session debugging. The interactive `clear()` debug method remains
|
|
166
|
+
* for manual use.
|
|
167
|
+
*/
|
|
168
|
+
export function clearRunHistory(): void {
|
|
169
|
+
runHistory.length = 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
116
172
|
/**
|
|
117
173
|
* Determine card origin from provenance trail.
|
|
118
174
|
*/
|
|
@@ -171,7 +227,11 @@ export function buildRunReport(
|
|
|
171
227
|
): Omit<PipelineRunReport, 'runId' | 'timestamp'> {
|
|
172
228
|
const selectedIds = new Set(selectedCards.map((c) => c.cardId));
|
|
173
229
|
|
|
174
|
-
|
|
230
|
+
// `allCards` arrives post-filter and sorted by score (desc). Partition
|
|
231
|
+
// into selected vs not-selected, then retain only the top-N of the
|
|
232
|
+
// non-selected group to bound memory. The remaining low-score tail is
|
|
233
|
+
// summarized rather than kept (see discardedTail).
|
|
234
|
+
const toReport = (card: WeightedCard) => ({
|
|
175
235
|
cardId: card.cardId,
|
|
176
236
|
courseId: card.courseId,
|
|
177
237
|
origin: getOrigin(card),
|
|
@@ -181,7 +241,55 @@ export function buildRunReport(
|
|
|
181
241
|
provenance: card.provenance,
|
|
182
242
|
tags: card.tags,
|
|
183
243
|
selected: selectedIds.has(card.cardId),
|
|
184
|
-
})
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const selectedReported: ReturnType<typeof toReport>[] = [];
|
|
247
|
+
const nearMissReported: ReturnType<typeof toReport>[] = [];
|
|
248
|
+
const discardedTailCards: WeightedCard[] = [];
|
|
249
|
+
|
|
250
|
+
let nonSelectedSeen = 0;
|
|
251
|
+
for (const card of allCards) {
|
|
252
|
+
if (selectedIds.has(card.cardId)) {
|
|
253
|
+
selectedReported.push(toReport(card));
|
|
254
|
+
} else if (nonSelectedSeen < DISCARDED_KEEP_TOP) {
|
|
255
|
+
nearMissReported.push(toReport(card));
|
|
256
|
+
nonSelectedSeen++;
|
|
257
|
+
} else {
|
|
258
|
+
discardedTailCards.push(card);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const cards = [...selectedReported, ...nearMissReported];
|
|
263
|
+
|
|
264
|
+
let discardedTail: PipelineRunReport['discardedTail'];
|
|
265
|
+
if (discardedTailCards.length > 0) {
|
|
266
|
+
let scoreMin = Infinity;
|
|
267
|
+
let scoreMax = -Infinity;
|
|
268
|
+
let eloMin = Infinity;
|
|
269
|
+
let eloMax = -Infinity;
|
|
270
|
+
let eloSeen = false;
|
|
271
|
+
for (const c of discardedTailCards) {
|
|
272
|
+
if (c.score < scoreMin) scoreMin = c.score;
|
|
273
|
+
if (c.score > scoreMax) scoreMax = c.score;
|
|
274
|
+
const elo = parseCardElo(c.provenance);
|
|
275
|
+
if (elo !== undefined) {
|
|
276
|
+
eloSeen = true;
|
|
277
|
+
if (elo < eloMin) eloMin = elo;
|
|
278
|
+
if (elo > eloMax) eloMax = elo;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const eloFragment = eloSeen ? `, ELO ${eloMin}–${eloMax}` : '';
|
|
282
|
+
discardedTail = {
|
|
283
|
+
count: discardedTailCards.length,
|
|
284
|
+
scoreRange: [scoreMin, scoreMax],
|
|
285
|
+
eloRange: eloSeen ? [eloMin, eloMax] : undefined,
|
|
286
|
+
note:
|
|
287
|
+
`${discardedTailCards.length} additional candidate(s) scored below the ` +
|
|
288
|
+
`top ${DISCARDED_KEEP_TOP} near-misses and were not retained ` +
|
|
289
|
+
`(score ${scoreMin.toExponential(2)}–${scoreMax.toExponential(2)}${eloFragment}). ` +
|
|
290
|
+
`Likely ELO-window pull remnants filtered out by hierarchy/lesson/priority gates.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
185
293
|
|
|
186
294
|
const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === 'review').length;
|
|
187
295
|
const newSelected = selectedCards.filter((c) => getOrigin(c) === 'new').length;
|
|
@@ -199,6 +307,7 @@ export function buildRunReport(
|
|
|
199
307
|
reviewsSelected,
|
|
200
308
|
newSelected,
|
|
201
309
|
cards,
|
|
310
|
+
discardedTail,
|
|
202
311
|
};
|
|
203
312
|
}
|
|
204
313
|
|
|
@@ -273,6 +382,50 @@ let _uiContainer: HTMLElement | null = null;
|
|
|
273
382
|
let _selectedRunIndex: number | null = null;
|
|
274
383
|
let _cardSearchQuery = '';
|
|
275
384
|
|
|
385
|
+
function escapeHtml(s: string): string {
|
|
386
|
+
return s
|
|
387
|
+
.replace(/&/g, '&')
|
|
388
|
+
.replace(/</g, '<')
|
|
389
|
+
.replace(/>/g, '>');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function escapeAttr(s: string): string {
|
|
393
|
+
return escapeHtml(s).replace(/"/g, '"');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function copyTextToClipboard(text: string, btn?: HTMLElement): void {
|
|
397
|
+
const done = () => {
|
|
398
|
+
if (!btn) return;
|
|
399
|
+
const orig = btn.textContent ?? 'Copy';
|
|
400
|
+
btn.textContent = 'Copied!';
|
|
401
|
+
btn.classList.add('copied');
|
|
402
|
+
setTimeout(() => {
|
|
403
|
+
btn.textContent = orig;
|
|
404
|
+
btn.classList.remove('copied');
|
|
405
|
+
}, 1200);
|
|
406
|
+
};
|
|
407
|
+
const fallback = () => {
|
|
408
|
+
const ta = document.createElement('textarea');
|
|
409
|
+
ta.value = text;
|
|
410
|
+
ta.style.position = 'fixed';
|
|
411
|
+
ta.style.opacity = '0';
|
|
412
|
+
document.body.appendChild(ta);
|
|
413
|
+
ta.select();
|
|
414
|
+
try {
|
|
415
|
+
document.execCommand('copy');
|
|
416
|
+
} catch (e) {
|
|
417
|
+
logger.warn(`[Pipeline Debug] Copy failed: ${e}`);
|
|
418
|
+
}
|
|
419
|
+
document.body.removeChild(ta);
|
|
420
|
+
done();
|
|
421
|
+
};
|
|
422
|
+
if (navigator.clipboard?.writeText) {
|
|
423
|
+
navigator.clipboard.writeText(text).then(done).catch(fallback);
|
|
424
|
+
} else {
|
|
425
|
+
fallback();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
276
429
|
function renderUI(): void {
|
|
277
430
|
if (!_uiContainer) return;
|
|
278
431
|
|
|
@@ -333,6 +486,13 @@ function renderUI(): void {
|
|
|
333
486
|
#sk-pipeline-debugger .close-btn { background: #dc3545; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
|
|
334
487
|
#sk-pipeline-debugger .search-box { margin-bottom: 1rem; width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; }
|
|
335
488
|
#sk-pipeline-debugger .provenance { font-size: 12px; color: #666; margin-top: 0.25rem; white-space: pre-wrap; font-family: monospace; background: #f8f9fa; padding: 0.5rem; border-radius: 4px; }
|
|
489
|
+
#sk-pipeline-debugger .run-label { display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.4rem; background: #fff3cd; color: #664d03; border-radius: 3px; font-family: monospace; font-size: 11px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
|
|
490
|
+
#sk-pipeline-debugger .label-banner { display: inline-block; padding: 0.25rem 0.6rem; background: #fff3cd; color: #664d03; border-radius: 4px; font-family: monospace; font-size: 13px; margin: 0 0 0.75rem 0; }
|
|
491
|
+
#sk-pipeline-debugger .copy-btn { background: #0d6efd; color: white; border: none; padding: 0.25rem 0.6rem; border-radius: 3px; cursor: pointer; font-size: 12px; margin-left: 0.5rem; }
|
|
492
|
+
#sk-pipeline-debugger .copy-btn:hover { background: #0b5ed7; }
|
|
493
|
+
#sk-pipeline-debugger .copy-btn.copied { background: #198754; }
|
|
494
|
+
#sk-pipeline-debugger .section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 1rem; }
|
|
495
|
+
#sk-pipeline-debugger .section-head h3 { margin: 0; }
|
|
336
496
|
`;
|
|
337
497
|
|
|
338
498
|
const runListHtml =
|
|
@@ -345,6 +505,7 @@ function renderUI(): void {
|
|
|
345
505
|
<strong>${r.timestamp.toLocaleTimeString()}</strong><br/>
|
|
346
506
|
<small>${r.courseName || r.courseId.slice(0, 8)}</small><br/>
|
|
347
507
|
<small>${r.finalCount} cards selected</small>
|
|
508
|
+
${r.hints?._label ? `<br/><span class="run-label" title="${escapeAttr(r.hints._label)}">${escapeHtml(r.hints._label)}</span>` : ''}
|
|
348
509
|
</div>
|
|
349
510
|
`
|
|
350
511
|
)
|
|
@@ -359,11 +520,13 @@ function renderUI(): void {
|
|
|
359
520
|
!_cardSearchQuery || c.cardId.toLowerCase().includes(_cardSearchQuery.toLowerCase())
|
|
360
521
|
);
|
|
361
522
|
|
|
523
|
+
const labelText = selectedRun.hints?._label ?? '(no label)';
|
|
362
524
|
detailsHtml = `
|
|
363
525
|
<h2>Run: ${selectedRun.runId}</h2>
|
|
526
|
+
<div class="label-banner" title="${escapeAttr(labelText)}">${escapeHtml(labelText)}</div>
|
|
364
527
|
<p>
|
|
365
|
-
<strong>Time:</strong> ${selectedRun.timestamp.toLocaleString()} |
|
|
366
|
-
<strong>Course:</strong> ${selectedRun.courseName || selectedRun.courseId} |
|
|
528
|
+
<strong>Time:</strong> ${selectedRun.timestamp.toLocaleString()} |
|
|
529
|
+
<strong>Course:</strong> ${selectedRun.courseName || selectedRun.courseId} |
|
|
367
530
|
<strong>User ELO:</strong> ${selectedRun.userElo ?? 'unknown'}
|
|
368
531
|
</p>
|
|
369
532
|
|
|
@@ -382,7 +545,10 @@ function renderUI(): void {
|
|
|
382
545
|
${
|
|
383
546
|
selectedRun.hints
|
|
384
547
|
? `
|
|
385
|
-
<
|
|
548
|
+
<div class="section-head">
|
|
549
|
+
<h3>Ephemeral Hints</h3>
|
|
550
|
+
<button class="copy-btn" onclick="window.skuilder.pipeline._copyConfig('${selectedRun.runId}', this)">Copy config</button>
|
|
551
|
+
</div>
|
|
386
552
|
<table>
|
|
387
553
|
${selectedRun.hints._label ? `<tr><th>Label</th><td>${selectedRun.hints._label}</td></tr>` : ''}
|
|
388
554
|
${
|
|
@@ -434,7 +600,10 @@ function renderUI(): void {
|
|
|
434
600
|
</tbody>
|
|
435
601
|
</table>
|
|
436
602
|
|
|
437
|
-
<
|
|
603
|
+
<div class="section-head">
|
|
604
|
+
<h3>Cards (${selectedRun.finalCount} selected / ${selectedRun.cards.length} total)</h3>
|
|
605
|
+
<button class="copy-btn" onclick="window.skuilder.pipeline._copyResults('${selectedRun.runId}', this)">Copy results</button>
|
|
606
|
+
</div>
|
|
438
607
|
<input type="text" class="search-box" placeholder="Search Card ID..." value="${_cardSearchQuery}" oninput="window.skuilder.pipeline._setSearch(this.value)">
|
|
439
608
|
|
|
440
609
|
<table>
|
|
@@ -554,7 +723,19 @@ export const pipelineDebugAPI = {
|
|
|
554
723
|
return;
|
|
555
724
|
}
|
|
556
725
|
}
|
|
557
|
-
|
|
726
|
+
// Not found in any retained card list. If runs have discarded tails, the
|
|
727
|
+
// card may have been in the low-score remnant pool — surface that as a
|
|
728
|
+
// breadcrumb rather than a flat "not found".
|
|
729
|
+
const runsWithTails = runHistory.filter((r) => r.discardedTail && r.discardedTail.count > 0);
|
|
730
|
+
if (runsWithTails.length > 0) {
|
|
731
|
+
logger.info(
|
|
732
|
+
`[Pipeline Debug] Card '${cardId}' not found in retained cards. ` +
|
|
733
|
+
`${runsWithTails.length} run(s) have discarded tails that were not retained — ` +
|
|
734
|
+
`the card may have been a low-score candidate. See run.discardedTail for ranges.`
|
|
735
|
+
);
|
|
736
|
+
} else {
|
|
737
|
+
logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
|
|
738
|
+
}
|
|
558
739
|
},
|
|
559
740
|
|
|
560
741
|
/**
|
|
@@ -932,6 +1113,55 @@ export const pipelineDebugAPI = {
|
|
|
932
1113
|
renderUI();
|
|
933
1114
|
},
|
|
934
1115
|
|
|
1116
|
+
/**
|
|
1117
|
+
* Internal UI helpers
|
|
1118
|
+
* @internal
|
|
1119
|
+
*/
|
|
1120
|
+
_copyConfig(runId: string, btn?: HTMLElement): void {
|
|
1121
|
+
const run = runHistory.find((r) => r.runId === runId);
|
|
1122
|
+
if (!run) return;
|
|
1123
|
+
const payload = {
|
|
1124
|
+
runId: run.runId,
|
|
1125
|
+
timestamp: run.timestamp.toISOString(),
|
|
1126
|
+
courseId: run.courseId,
|
|
1127
|
+
courseName: run.courseName,
|
|
1128
|
+
hints: run.hints ?? null,
|
|
1129
|
+
};
|
|
1130
|
+
copyTextToClipboard(JSON.stringify(payload, null, 2), btn);
|
|
1131
|
+
},
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Internal UI helpers
|
|
1135
|
+
* @internal
|
|
1136
|
+
*
|
|
1137
|
+
* Copies an "abridged" view of results: just the selected cards with their
|
|
1138
|
+
* generator, origin, final score, and the top provenance reason. Designed
|
|
1139
|
+
* for pasting into bug reports without flooding with full provenance.
|
|
1140
|
+
*/
|
|
1141
|
+
_copyResults(runId: string, btn?: HTMLElement): void {
|
|
1142
|
+
const run = runHistory.find((r) => r.runId === runId);
|
|
1143
|
+
if (!run) return;
|
|
1144
|
+
const selected = run.cards
|
|
1145
|
+
.filter((c) => c.selected)
|
|
1146
|
+
.sort((a, b) => b.finalScore - a.finalScore)
|
|
1147
|
+
.map((c) => ({
|
|
1148
|
+
cardId: c.cardId,
|
|
1149
|
+
generator: c.generator,
|
|
1150
|
+
origin: c.origin,
|
|
1151
|
+
score: Number(c.finalScore.toFixed(3)),
|
|
1152
|
+
topReason: c.provenance[0]?.reason ?? '',
|
|
1153
|
+
}));
|
|
1154
|
+
const payload = {
|
|
1155
|
+
runId: run.runId,
|
|
1156
|
+
label: run.hints?._label ?? null,
|
|
1157
|
+
finalCount: run.finalCount,
|
|
1158
|
+
newSelected: run.newSelected,
|
|
1159
|
+
reviewsSelected: run.reviewsSelected,
|
|
1160
|
+
selected,
|
|
1161
|
+
};
|
|
1162
|
+
copyTextToClipboard(JSON.stringify(payload, null, 2), btn);
|
|
1163
|
+
},
|
|
1164
|
+
|
|
935
1165
|
/**
|
|
936
1166
|
* Show help.
|
|
937
1167
|
*/
|
|
@@ -686,7 +686,12 @@ Currently logged-in as ${this._username}.`
|
|
|
686
686
|
_rev: existingDoc._rev,
|
|
687
687
|
});
|
|
688
688
|
} catch (e: unknown) {
|
|
689
|
-
|
|
689
|
+
// NB: PouchDB errors are plain objects ({error, reason, status, name,
|
|
690
|
+
// message, ...}), not Error instances — `e instanceof Error` is false
|
|
691
|
+
// for them. Check `.name` directly. The outer catch at line ~697
|
|
692
|
+
// already does this correctly; mirror that style here so the
|
|
693
|
+
// not_found → "create new doc" path actually fires for fresh user DBs.
|
|
694
|
+
if ((e as { name?: string })?.name === 'not_found') {
|
|
690
695
|
// Create new doc
|
|
691
696
|
await this.remoteDB.put(doc);
|
|
692
697
|
} else {
|
|
@@ -716,7 +721,10 @@ Currently logged-in as ${this._username}.`
|
|
|
716
721
|
_rev: existingDoc._rev,
|
|
717
722
|
});
|
|
718
723
|
} catch (e: unknown) {
|
|
719
|
-
|
|
724
|
+
// NB: PouchDB errors are plain objects, not Error instances — see
|
|
725
|
+
// applyDesignDocs() above. Check `.name` directly so the conflict-retry
|
|
726
|
+
// path actually fires.
|
|
727
|
+
if ((e as { name?: string })?.name === 'conflict' && retries > 0) {
|
|
720
728
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
721
729
|
return this.applyDesignDoc(doc, retries - 1);
|
|
722
730
|
}
|
|
@@ -343,8 +343,19 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
343
343
|
/**
|
|
344
344
|
* Request a mid-session replan. Re-runs the pipeline with current user state
|
|
345
345
|
* and atomically replaces the newQ contents. Safe to call at any time during
|
|
346
|
-
* a session
|
|
347
|
-
*
|
|
346
|
+
* a session.
|
|
347
|
+
*
|
|
348
|
+
* Concurrency policy:
|
|
349
|
+
* - Two unhinted auto-replans never run in parallel; the second coalesces
|
|
350
|
+
* into the first (returns the same promise).
|
|
351
|
+
* - A hint-bearing replan that arrives while another replan is in flight
|
|
352
|
+
* is queued to run **after** the in-flight one rather than dropped.
|
|
353
|
+
* This preserves caller intent (label, requireCards, excludeTags,
|
|
354
|
+
* limit, minFollowUpCards) instead of silently discarding it. Without
|
|
355
|
+
* queueing, a background auto-replan that started just before a
|
|
356
|
+
* completion-triggered replan would clobber the queue with unhinted
|
|
357
|
+
* results (e.g. surfacing another gpc-intro card right after one
|
|
358
|
+
* completed, skipping the prescribed `c-wst-*` follow-up).
|
|
348
359
|
*
|
|
349
360
|
* Does NOT affect reviewQ or failedQ.
|
|
350
361
|
*
|
|
@@ -365,11 +376,69 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
365
376
|
this._depletionReplanAttempted = false;
|
|
366
377
|
}
|
|
367
378
|
|
|
379
|
+
const hasIntent = this._replanHasIntent(opts);
|
|
380
|
+
|
|
368
381
|
if (this._replanPromise) {
|
|
369
|
-
|
|
370
|
-
|
|
382
|
+
if (!hasIntent) {
|
|
383
|
+
this.log('Replan already in progress, coalescing unhinted auto-replan');
|
|
384
|
+
return this._replanPromise;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Queue the hint-bearing replan behind the in-flight one rather than
|
|
388
|
+
// dropping its hints. See class comment above for rationale.
|
|
389
|
+
const labelTag = opts.label ? ` [${opts.label}]` : '';
|
|
390
|
+
this.log(
|
|
391
|
+
`Replan in progress; queueing hint-bearing replan${labelTag} behind in-flight run`
|
|
392
|
+
);
|
|
393
|
+
const inflight = this._replanPromise;
|
|
394
|
+
const queued: Promise<void> = inflight
|
|
395
|
+
// Swallow errors in the upstream replan so the downstream still runs
|
|
396
|
+
// (the user's intent is independent of whether the prior run succeeded).
|
|
397
|
+
.catch(() => undefined)
|
|
398
|
+
.then(() => this._runReplan(opts));
|
|
399
|
+
|
|
400
|
+
this._replanPromise = queued.finally(() => {
|
|
401
|
+
if (this._replanPromise === queued) this._replanPromise = null;
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return queued;
|
|
371
405
|
}
|
|
372
406
|
|
|
407
|
+
const run = this._runReplan(opts);
|
|
408
|
+
this._replanPromise = run.finally(() => {
|
|
409
|
+
if (this._replanPromise === run) this._replanPromise = null;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await run;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* True when a requestReplan call carries caller intent that must not be
|
|
417
|
+
* silently dropped. Bare unhinted auto-replans (depletion / quality
|
|
418
|
+
* triggers in nextCard) return false and may coalesce.
|
|
419
|
+
*/
|
|
420
|
+
private _replanHasIntent(opts: ReplanOptions): boolean {
|
|
421
|
+
if (opts.label) return true;
|
|
422
|
+
if (opts.limit !== undefined) return true;
|
|
423
|
+
if (opts.minFollowUpCards !== undefined) return true;
|
|
424
|
+
if (opts.mode && opts.mode !== 'replace') return true;
|
|
425
|
+
if (opts.hints && Object.keys(opts.hints).length > 0) return true;
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Body of a single replan: populate auto-excludes, stash hints on
|
|
431
|
+
* sources, log, then run the pipeline. Extracted so it can be invoked
|
|
432
|
+
* either immediately (no in-flight replan) or queued (chained after
|
|
433
|
+
* the in-flight one resolves).
|
|
434
|
+
*
|
|
435
|
+
* IMPORTANT: hint stash and the queue-state snapshot used to build
|
|
436
|
+
* excludeCards happen at *invocation* time, not at *queue* time. For a
|
|
437
|
+
* queued replan that means excludes reflect the state after the prior
|
|
438
|
+
* replan landed — which is what we want, since the prior replan's
|
|
439
|
+
* newQ.peek(0) is the imminent draw we need to exclude.
|
|
440
|
+
*/
|
|
441
|
+
private async _runReplan(opts: ReplanOptions): Promise<void> {
|
|
373
442
|
// Exclude all cards already presented this session. The pipeline may
|
|
374
443
|
// not yet see their encounter records (async writes), so without this
|
|
375
444
|
// they can re-enter newQ via replaceAll and cause duplicates.
|
|
@@ -423,13 +492,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
423
492
|
this.log(`[Replan] Card guarantee set to ${this._minCardsGuarantee}`);
|
|
424
493
|
}
|
|
425
494
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
try {
|
|
429
|
-
await this._replanPromise;
|
|
430
|
-
} finally {
|
|
431
|
-
this._replanPromise = null;
|
|
432
|
-
}
|
|
495
|
+
await this._executeReplan(opts);
|
|
433
496
|
}
|
|
434
497
|
|
|
435
498
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { logger } from '../util/logger';
|
|
2
|
+
import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
|
|
2
3
|
|
|
3
4
|
// ============================================================================
|
|
4
5
|
// SESSION DEBUGGER
|
|
@@ -75,6 +76,13 @@ export function startSessionTracking(
|
|
|
75
76
|
newQLength: number,
|
|
76
77
|
failedQLength: number
|
|
77
78
|
): void {
|
|
79
|
+
// Release pipeline run-history memory before this session begins piling
|
|
80
|
+
// new runs on top of the previous session's retained reports. Clearing on
|
|
81
|
+
// session START (rather than END) keeps post-session inspection — the
|
|
82
|
+
// dominant debugging workflow — fully functional: a finished session's
|
|
83
|
+
// run history sits intact until the user actually begins another one.
|
|
84
|
+
clearPipelineRunHistory();
|
|
85
|
+
|
|
78
86
|
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
79
87
|
|
|
80
88
|
activeSession = {
|