@dotdrelle/wiki-manager 0.15.97 → 0.15.99
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/package.json +2 -2
- package/src/agent/graph.js +45 -7
- package/src/agent/graph.test.js +45 -0
- package/src/cli/wiki-manager.js +47 -33
- package/src/commands/slash.js +7 -2
- package/src/contracts/schemas.js +8 -17
- package/src/contracts/schemas.test.js +15 -0
- package/src/core/agentEvents.js +45 -7
- package/src/core/agentEvents.test.js +65 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeEventAdapter.js +99 -1
- package/src/core/runtimeEventAdapter.test.js +92 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/testGate.test.js +33 -0
- package/src/core/toolLoop.js +14 -2
- package/src/core/toolLoop.test.js +28 -0
- package/src/orchestrator/dispatcher.js +19 -0
- package/src/orchestrator/knowledgeSignals.js +260 -0
- package/src/orchestrator/knowledgeSignals.test.js +193 -0
- package/src/orchestrator/proactiveReviewScheduler.js +240 -0
- package/src/orchestrator/proactiveReviewScheduler.test.js +243 -0
- package/src/orchestrator/providers/deepAgentsProvider.js +134 -29
- package/src/orchestrator/providers/deepAgentsProvider.test.js +138 -3
- package/src/orchestrator/resultAggregator.js +115 -1
- package/src/orchestrator/resultAggregator.test.js +138 -0
- package/src/runtime/controlClassify.test.js +31 -0
- package/src/runtime/runner.js +13 -4
- package/src/runtime/runner.test.js +20 -0
- package/src/runtime/server.js +256 -4
- package/src/runtime/server.test.js +13 -1
- package/src/runtime/store.js +1 -1
- package/src/runtime/store.test.js +5 -1
- package/src/shell/openExternal.js +43 -0
- package/src/shell/repl.js +1 -1
- package/wiki-workspace +0 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
conflictFingerprint,
|
|
9
|
+
detectConceptConflicts,
|
|
10
|
+
detectStaleKnowledge,
|
|
11
|
+
normalizeSubject,
|
|
12
|
+
readConceptLeaves,
|
|
13
|
+
readSourceRegistry,
|
|
14
|
+
readWikiPages,
|
|
15
|
+
staleFingerprint,
|
|
16
|
+
} from './knowledgeSignals.js';
|
|
17
|
+
|
|
18
|
+
test('normalizeSubject folds case, accents and punctuation', () => {
|
|
19
|
+
assert.equal(normalizeSubject('Jedox Cloud'), 'jedox-cloud');
|
|
20
|
+
assert.equal(normalizeSubject('jedox-cloud'), 'jedox-cloud');
|
|
21
|
+
assert.equal(normalizeSubject('Souveraineté'), 'souverainete');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('two homonym leaves under one concept are one conflict, not two', () => {
|
|
25
|
+
const { conflicts, total } = detectConceptConflicts([
|
|
26
|
+
{ path: 'saas/jedox.md', concept: 'saas', subject: 'Jedox' },
|
|
27
|
+
{ path: 'saas/jedox-cloud.md', concept: 'saas', subject: 'jedox' },
|
|
28
|
+
{ path: 'cout/jedox.md', concept: 'cout', subject: 'Jedox' },
|
|
29
|
+
{ path: 'saas/anaplan.md', concept: 'saas', subject: 'Anaplan' },
|
|
30
|
+
]);
|
|
31
|
+
assert.equal(total, 1);
|
|
32
|
+
assert.deepEqual(conflicts, [
|
|
33
|
+
{ concept: 'saas', subject: 'jedox', paths: ['saas/jedox-cloud.md', 'saas/jedox.md'] },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a conflict in nested sub-folders keeps both distinct paths', () => {
|
|
38
|
+
const { conflicts, total } = detectConceptConflicts([
|
|
39
|
+
{ path: 'saas/produits/jedox.md', concept: 'saas', subject: 'Jedox' },
|
|
40
|
+
{ path: 'saas/vendors/jedox.md', concept: 'saas', subject: 'Jedox' },
|
|
41
|
+
]);
|
|
42
|
+
assert.equal(total, 1);
|
|
43
|
+
assert.deepEqual(conflicts[0].paths, ['saas/produits/jedox.md', 'saas/vendors/jedox.md']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the conflict fingerprint is stable and moves past the display ceiling', () => {
|
|
47
|
+
const a = [{ concept: 'saas', subject: 'jedox', paths: ['saas/b.md', 'saas/a.md'] }];
|
|
48
|
+
const b = [{ concept: 'saas', subject: 'jedox', paths: ['saas/a.md', 'saas/b.md'] }];
|
|
49
|
+
assert.equal(conflictFingerprint(a, 1), conflictFingerprint(b, 1));
|
|
50
|
+
// A 51st conflict beyond the cap must change the version, or it dedups away.
|
|
51
|
+
assert.notEqual(conflictFingerprint(a, 50), conflictFingerprint(a, 51));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the ceiling reports what it dropped instead of hiding it', () => {
|
|
55
|
+
const leaves = Array.from({ length: 60 }, (_, index) => [
|
|
56
|
+
{ path: `c/s${index}-a.md`, concept: 'c', subject: `s${index}` },
|
|
57
|
+
{ path: `c/s${index}-b.md`, concept: 'c', subject: `s${index}` },
|
|
58
|
+
]).flat();
|
|
59
|
+
const { conflicts, total, dropped } = detectConceptConflicts(leaves);
|
|
60
|
+
assert.equal(conflicts.length, 50);
|
|
61
|
+
assert.equal(total, 60);
|
|
62
|
+
assert.equal(dropped, 10);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('readConceptLeaves takes the folder as the concept and the frontmatter as the subject', () => {
|
|
66
|
+
const root = mkdtempSync(join(tmpdir(), 'signals-'));
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(join(root, 'wiki', 'concepts', 'saas', 'produits'), { recursive: true });
|
|
69
|
+
mkdirSync(join(root, 'wiki', 'concepts', 'saas', 'vendors'), { recursive: true });
|
|
70
|
+
mkdirSync(join(root, 'wiki', 'concepts', 'cout'), { recursive: true });
|
|
71
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'saas', 'produits', 'jedox.md'), '---\nsubject: Jedox\n---\nbody');
|
|
72
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'saas', 'vendors', 'jedox.md'), '---\nsubject: Jedox\n---\nbody');
|
|
73
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'cout', 'jedox.md'), 'no frontmatter\n');
|
|
74
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'README.txt'), 'ignore me');
|
|
75
|
+
|
|
76
|
+
const leaves = readConceptLeaves(root);
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
leaves.map((leaf) => leaf.path).sort(),
|
|
79
|
+
['cout/jedox.md', 'saas/produits/jedox.md', 'saas/vendors/jedox.md'],
|
|
80
|
+
);
|
|
81
|
+
const { conflicts, total } = detectConceptConflicts(leaves);
|
|
82
|
+
assert.equal(total, 1);
|
|
83
|
+
assert.equal(conflicts[0].concept, 'saas');
|
|
84
|
+
assert.deepEqual(conflicts[0].paths, ['saas/produits/jedox.md', 'saas/vendors/jedox.md']);
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(root, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a corpus without homonyms produces no signal', () => {
|
|
91
|
+
assert.equal(
|
|
92
|
+
detectConceptConflicts([
|
|
93
|
+
{ path: 'saas/a.md', concept: 'saas', subject: 'A' },
|
|
94
|
+
{ path: 'saas/b.md', concept: 'saas', subject: 'B' },
|
|
95
|
+
]).total,
|
|
96
|
+
0,
|
|
97
|
+
);
|
|
98
|
+
assert.equal(detectConceptConflicts([]).total, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('stale knowledge is aged sources plus registry paths that no longer exist', () => {
|
|
102
|
+
const now = Date.parse('2026-06-01T00:00:00.000Z');
|
|
103
|
+
const recent = '2026-05-30T00:00:00.000Z';
|
|
104
|
+
const registry = {
|
|
105
|
+
sources: [
|
|
106
|
+
{ sourceId: 'a', archivePath: 'raw/ingested/a.md', status: 'active', lastIngestedAt: recent, producedPages: ['wiki/concepts/saas/a.md'] },
|
|
107
|
+
{ sourceId: 'b', archivePath: 'raw/ingested/b.md', status: 'active', lastIngestedAt: recent, producedPages: [] },
|
|
108
|
+
{ sourceId: 'e', archivePath: 'raw/ingested/e.md', status: 'active', lastIngestedAt: recent, producedPages: ['wiki/concepts/saas/e-gone.md'] },
|
|
109
|
+
{ sourceId: 'f', archivePath: 'raw/ingested/f.md', status: 'active', lastIngestedAt: '2025-01-01T00:00:00.000Z', producedPages: [] },
|
|
110
|
+
{ sourceId: 'g', archivePath: 'raw/ingested/g.md', status: 'retracted', lastIngestedAt: '2024-01-01T00:00:00.000Z', producedPages: [] },
|
|
111
|
+
{ sourceId: 'h', archivePath: 'raw/ingested/h.md', status: 'active', lastIngestedAt: null, producedPages: [] },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
// Only b's archive and e's produced page are gone.
|
|
115
|
+
const exists = (path) => !path.includes('raw/ingested/b.md') && !path.endsWith('e-gone.md');
|
|
116
|
+
|
|
117
|
+
const { stale, total, dropped, counts } = detectStaleKnowledge(registry, {
|
|
118
|
+
rootDir: '/ws', now, staleAfterDays: 180, exists,
|
|
119
|
+
});
|
|
120
|
+
assert.equal(total, 3, 'a recent, a retracted and a never-ingested source are not stale');
|
|
121
|
+
assert.equal(dropped, 0);
|
|
122
|
+
assert.deepEqual(counts, { aged: 1, vanishedArchive: 1, vanishedPage: 1, orphan: 0 });
|
|
123
|
+
assert.deepEqual(stale.map((entry) => entry.kind), ['aged', 'vanished-archive', 'vanished-page']);
|
|
124
|
+
assert.equal(stale.find((entry) => entry.kind === 'aged').sourceId, 'f');
|
|
125
|
+
assert.equal(stale.find((entry) => entry.kind === 'vanished-archive').path, 'raw/ingested/b.md');
|
|
126
|
+
assert.equal(stale.find((entry) => entry.kind === 'vanished-page').path, 'wiki/concepts/saas/e-gone.md');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('the stale fingerprint is stable and counts the whole set', () => {
|
|
130
|
+
const a = [{ kind: 'aged', sourceId: 'b', path: 'raw/ingested/b.md', lastIngestedAt: '2025-01-01T00:00:00.000Z' }];
|
|
131
|
+
assert.equal(staleFingerprint(a, 1), staleFingerprint(a, 1));
|
|
132
|
+
assert.notEqual(staleFingerprint(a, 1), staleFingerprint(a, 2));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('readSourceRegistry tolerates an absent or corrupt file', () => {
|
|
136
|
+
const root = mkdtempSync(join(tmpdir(), 'registry-'));
|
|
137
|
+
try {
|
|
138
|
+
assert.deepEqual(readSourceRegistry(root), { sources: [] });
|
|
139
|
+
mkdirSync(join(root, '.wiki'), { recursive: true });
|
|
140
|
+
writeFileSync(join(root, '.wiki', 'source-registry.json'), '{not json');
|
|
141
|
+
assert.deepEqual(readSourceRegistry(root), { sources: [] });
|
|
142
|
+
writeFileSync(
|
|
143
|
+
join(root, '.wiki', 'source-registry.json'),
|
|
144
|
+
JSON.stringify({ version: 1, sources: [{ sourceId: 'a' }] }),
|
|
145
|
+
);
|
|
146
|
+
assert.equal(readSourceRegistry(root).sources.length, 1);
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(root, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a wiki page no active source backs is an orphan fact', () => {
|
|
153
|
+
const now = Date.parse('2026-06-01T00:00:00.000Z');
|
|
154
|
+
const recent = '2026-05-30T00:00:00.000Z';
|
|
155
|
+
const registry = {
|
|
156
|
+
sources: [
|
|
157
|
+
{ sourceId: 'a', archivePath: 'raw/ingested/a.md', status: 'active', lastIngestedAt: recent, producedPages: ['wiki/concepts/saas/a.md'] },
|
|
158
|
+
{ sourceId: 'b', archivePath: 'raw/ingested/b.md', status: 'retracted', lastIngestedAt: recent, producedPages: ['wiki/concepts/saas/retracted.md'] },
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
const { stale, counts } = detectStaleKnowledge(registry, {
|
|
162
|
+
rootDir: '/ws',
|
|
163
|
+
now,
|
|
164
|
+
staleAfterDays: 180,
|
|
165
|
+
exists: () => true,
|
|
166
|
+
wikiPages: ['wiki/concepts/saas/a.md', 'wiki/concepts/saas/handwritten.md', 'wiki/concepts/saas/retracted.md'],
|
|
167
|
+
});
|
|
168
|
+
// A page backed by an ACTIVE source is not an orphan; a hand-written one and
|
|
169
|
+
// one produced by a RETRACTED source are.
|
|
170
|
+
assert.deepEqual(
|
|
171
|
+
stale.filter((entry) => entry.kind === 'orphan').map((entry) => entry.path),
|
|
172
|
+
['wiki/concepts/saas/handwritten.md', 'wiki/concepts/saas/retracted.md'],
|
|
173
|
+
);
|
|
174
|
+
assert.equal(counts.orphan, 2);
|
|
175
|
+
|
|
176
|
+
// Without the inventory, no orphan kind is invented.
|
|
177
|
+
const without = detectStaleKnowledge(registry, { rootDir: '/ws', now, exists: () => true });
|
|
178
|
+
assert.equal(without.stale.some((entry) => entry.kind === 'orphan'), false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('readWikiPages inventories wiki/**/*.md, names only', () => {
|
|
182
|
+
const root = mkdtempSync(join(tmpdir(), 'wikipages-'));
|
|
183
|
+
try {
|
|
184
|
+
mkdirSync(join(root, 'wiki', 'concepts', 'saas'), { recursive: true });
|
|
185
|
+
mkdirSync(join(root, 'wiki', 'answers'), { recursive: true });
|
|
186
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'saas', 'a.md'), 'x');
|
|
187
|
+
writeFileSync(join(root, 'wiki', 'answers', 'b.md'), 'x');
|
|
188
|
+
writeFileSync(join(root, 'wiki', 'notes.txt'), 'x');
|
|
189
|
+
assert.deepEqual(readWikiPages(root), ['wiki/answers/b.md', 'wiki/concepts/saas/a.md']);
|
|
190
|
+
} finally {
|
|
191
|
+
rmSync(root, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Proactive reviews: the manager noticing, from a fact it already computed, that
|
|
3
|
+
a workspace is worth auditing — and PROPOSING one, never mutating anything.
|
|
4
|
+
|
|
5
|
+
This module is the deterministic decision only: which task completion is a
|
|
6
|
+
trigger, whether the workspace's opt-in config allows one, and the dedup /
|
|
7
|
+
cooldown / budget arithmetic. Enqueueing the run and persisting the resulting
|
|
8
|
+
note belong to the runtime; keeping the decision pure is what makes it
|
|
9
|
+
testable without a session, a queue or a model.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Read-only, no worktree: the review capability the gateway already declares.
|
|
13
|
+
export const PROACTIVE_REVIEW_CAPABILITY = 'agent.review';
|
|
14
|
+
|
|
15
|
+
export const PROACTIVE_DEFAULTS = {
|
|
16
|
+
enabled: false,
|
|
17
|
+
triggers: [
|
|
18
|
+
'knowledge.ingested',
|
|
19
|
+
'knowledge.rebuilt',
|
|
20
|
+
'knowledge.stale',
|
|
21
|
+
'knowledge.conflict_detected',
|
|
22
|
+
],
|
|
23
|
+
cooldownMs: 6 * 60 * 60 * 1000,
|
|
24
|
+
runsPerDay: 4,
|
|
25
|
+
concurrency: 1,
|
|
26
|
+
staleAfterDays: 180,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Which task completion is a trigger. Derived from the capability/operation
|
|
31
|
+
* the production agent actually exposes — never a registered list an agent
|
|
32
|
+
* could silently stop matching.
|
|
33
|
+
*/
|
|
34
|
+
export function triggerForTask({ capability, operation } = {}) {
|
|
35
|
+
const cap = String(capability ?? '').trim();
|
|
36
|
+
const op = String(operation ?? '').trim();
|
|
37
|
+
// `ingest_plan` is a dry-run under `knowledge.update` (read lock, writes only
|
|
38
|
+
// `.wiki/ingest-plans/`): the corpus has not moved, so it must be excluded
|
|
39
|
+
// BEFORE the capability check that would otherwise accept it.
|
|
40
|
+
if (op === 'ingest_plan') return null;
|
|
41
|
+
if (cap === 'knowledge.rebuild' || op === 'ingest_rebuild') return 'knowledge.rebuilt';
|
|
42
|
+
if (
|
|
43
|
+
cap === 'knowledge.update'
|
|
44
|
+
|| cap === 'knowledge.pipeline' // the default one-shot path: it ingests too
|
|
45
|
+
|| op === 'ingest'
|
|
46
|
+
|| op === 'ingest_apply'
|
|
47
|
+
|| op === 'pipeline'
|
|
48
|
+
) {
|
|
49
|
+
return 'knowledge.ingested';
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const EVIDENCE_CHARS = 600;
|
|
55
|
+
|
|
56
|
+
// The detector's exact facts, named for the agent. A fingerprint alone makes it
|
|
57
|
+
// re-read the whole wiki to rediscover what the deterministic scan already
|
|
58
|
+
// established — and a vanished page is an ABSENCE, which cannot be rediscovered
|
|
59
|
+
// by reading. Bounded: the objective is a briefing, not a dump.
|
|
60
|
+
function evidenceDetail(evidence) {
|
|
61
|
+
if (!evidence || typeof evidence !== 'object') return '';
|
|
62
|
+
const items = Array.isArray(evidence.items) ? evidence.items : [];
|
|
63
|
+
const listed = evidence.kind === 'conflict'
|
|
64
|
+
? items.slice(0, 5).map((item) => `${item.concept}/${item.subject} (${(item.paths ?? []).join(', ')})`)
|
|
65
|
+
: items.slice(0, 5).map((item) => `${item.kind}: ${item.path}`);
|
|
66
|
+
const more = items.length > 5 ? '; …' : '';
|
|
67
|
+
if (evidence.kind === 'conflict') {
|
|
68
|
+
return `${items.length} homonym leaf group(s): ${listed.join('; ')}${more}`;
|
|
69
|
+
}
|
|
70
|
+
if (evidence.kind === 'stale') {
|
|
71
|
+
const counts = evidence.counts ?? {};
|
|
72
|
+
const parts = [
|
|
73
|
+
counts.aged ? `${counts.aged} aged source(s)` : null,
|
|
74
|
+
counts.vanishedArchive ? `${counts.vanishedArchive} vanished archive(s)` : null,
|
|
75
|
+
counts.vanishedPage ? `${counts.vanishedPage} vanished page(s)` : null,
|
|
76
|
+
].filter(Boolean).join(', ');
|
|
77
|
+
return `${parts}${listed.length ? ` — ${listed.join('; ')}` : ''}${more}`;
|
|
78
|
+
}
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The objective a proactive review runs under. Routing is EXPLICIT (a
|
|
84
|
+
* `capabilityPlan` naming `agent.review`), so the evidence below may name any
|
|
85
|
+
* path without risking the alias resolver — the reviewer is told WHAT the scan
|
|
86
|
+
* found, not a fingerprint to re-derive it from.
|
|
87
|
+
*/
|
|
88
|
+
export function buildProactiveReviewObjective({ trigger, sourceVersion, evidence = null } = {}) {
|
|
89
|
+
const version = sourceVersion ? ` (source ${sourceVersion})` : '';
|
|
90
|
+
const fact = String(trigger ?? 'a knowledge change');
|
|
91
|
+
const detail = evidenceDetail(evidence).slice(0, EVIDENCE_CHARS);
|
|
92
|
+
return [
|
|
93
|
+
`audit the workspace: ${fact}${version} just landed.`,
|
|
94
|
+
detail ? `The deterministic scan already found: ${detail}.` : '',
|
|
95
|
+
'Read the wiki and describe the gaps — no changes, no worktree.',
|
|
96
|
+
].filter(Boolean).join(' ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The workspace's opt-in block (`proactiveReviews` in `.wikirc.yaml`). Missing
|
|
101
|
+
* or malformed means DISABLED: a typo must never turn on spend the operator
|
|
102
|
+
* did not ask for.
|
|
103
|
+
*/
|
|
104
|
+
export function normalizeProactiveConfig(value) {
|
|
105
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
106
|
+
return { ...PROACTIVE_DEFAULTS, triggers: [...PROACTIVE_DEFAULTS.triggers] };
|
|
107
|
+
}
|
|
108
|
+
const positive = (raw, fallback) => {
|
|
109
|
+
const number = Number(raw);
|
|
110
|
+
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
|
111
|
+
};
|
|
112
|
+
const triggers = Array.isArray(value.triggers)
|
|
113
|
+
? value.triggers.map((trigger) => String(trigger).trim()).filter(Boolean)
|
|
114
|
+
: [...PROACTIVE_DEFAULTS.triggers];
|
|
115
|
+
return {
|
|
116
|
+
enabled: value.enabled === true,
|
|
117
|
+
triggers,
|
|
118
|
+
cooldownMs: positive(value.cooldownMs, PROACTIVE_DEFAULTS.cooldownMs),
|
|
119
|
+
runsPerDay: positive(value?.budget?.runsPerDay, PROACTIVE_DEFAULTS.runsPerDay),
|
|
120
|
+
concurrency: Math.max(1, positive(value.concurrency, PROACTIVE_DEFAULTS.concurrency)),
|
|
121
|
+
// How old a source's last ingest must be before `knowledge.stale` fires.
|
|
122
|
+
staleAfterDays: positive(value.staleAfterDays, PROACTIVE_DEFAULTS.staleAfterDays),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
127
|
+
|
|
128
|
+
function workspaceState(state, workspace) {
|
|
129
|
+
const key = String(workspace ?? '');
|
|
130
|
+
let entry = state.get(key);
|
|
131
|
+
if (!entry) {
|
|
132
|
+
entry = { lastFiredAt: null, seen: new Set(), dayStartedAt: null, dayCount: 0, inFlight: 0 };
|
|
133
|
+
state.set(key, entry);
|
|
134
|
+
}
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createProactiveReviewScheduler({ now = () => Date.now(), db = null, pendingReviews = null } = {}) {
|
|
139
|
+
// Durable spend and dedup belong beside the runtime queue, in its SQLite DB.
|
|
140
|
+
// Slots are reconciled with the persisted control queue before each decision.
|
|
141
|
+
db?.exec('CREATE TABLE IF NOT EXISTS proactive_review_state (workspace TEXT PRIMARY KEY, payload TEXT NOT NULL)');
|
|
142
|
+
const save = db?.prepare('INSERT INTO proactive_review_state (workspace, payload) VALUES (?, ?) ON CONFLICT(workspace) DO UPDATE SET payload = excluded.payload');
|
|
143
|
+
const state = new Map((db?.prepare('SELECT workspace, payload FROM proactive_review_state').all() ?? []).map((row) => {
|
|
144
|
+
const entry = JSON.parse(row.payload);
|
|
145
|
+
return [row.workspace, { ...entry, seen: new Set(entry.seen), inFlight: 0, inFlightTrigger: null }];
|
|
146
|
+
}));
|
|
147
|
+
function persist(workspace, entry) {
|
|
148
|
+
save?.run(String(workspace ?? ''), JSON.stringify({ ...entry, seen: [...entry.seen] }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function decide({ workspace, trigger, sourceVersion = null, config = null } = {}) {
|
|
152
|
+
const cfg = normalizeProactiveConfig(config);
|
|
153
|
+
const skip = (reason) => ({ action: 'skip', reason, config: cfg });
|
|
154
|
+
if (!cfg.enabled) return skip('disabled');
|
|
155
|
+
if (!cfg.triggers.includes(String(trigger ?? ''))) return skip('trigger_disabled');
|
|
156
|
+
if (pendingReviews) {
|
|
157
|
+
for (const item of state.values()) { item.inFlight = 0; item.inFlightTrigger = null; }
|
|
158
|
+
for (const review of pendingReviews()) {
|
|
159
|
+
const pending = workspaceState(state, review.workspace);
|
|
160
|
+
pending.inFlight += 1;
|
|
161
|
+
pending.inFlightTrigger = review.trigger;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const entry = workspaceState(state, workspace);
|
|
165
|
+
const at = now();
|
|
166
|
+
// The daily window is what bounds `seen`: pruning it at rollover keeps
|
|
167
|
+
// dedup per-day without an ever-growing Set in a long-lived process (the
|
|
168
|
+
// same family as MAX_SESSION_EVENTS and run.events).
|
|
169
|
+
if (entry.dayStartedAt == null || at - entry.dayStartedAt >= DAY_MS) {
|
|
170
|
+
entry.dayStartedAt = at;
|
|
171
|
+
entry.dayCount = 0;
|
|
172
|
+
entry.seen.clear();
|
|
173
|
+
}
|
|
174
|
+
// The same source version is the same review: a second task of one ingest
|
|
175
|
+
// run must not queue a second audit.
|
|
176
|
+
if (sourceVersion != null && entry.seen.has(String(sourceVersion))) return skip('duplicate');
|
|
177
|
+
if (entry.inFlight >= cfg.concurrency) return skip('concurrency');
|
|
178
|
+
if (entry.lastFiredAt != null && at - entry.lastFiredAt < cfg.cooldownMs) return skip('cooldown');
|
|
179
|
+
if (entry.dayCount >= cfg.runsPerDay) return skip('budget');
|
|
180
|
+
// Each workspace can tighten its own limit, but cannot exceed the global
|
|
181
|
+
// default by opening another workspace.
|
|
182
|
+
const globalInFlight = [...state.values()].reduce((sum, item) => sum + item.inFlight, 0);
|
|
183
|
+
if (globalInFlight >= PROACTIVE_DEFAULTS.concurrency) return skip('concurrency');
|
|
184
|
+
|
|
185
|
+
if (sourceVersion != null) entry.seen.add(String(sourceVersion));
|
|
186
|
+
entry.lastFiredAt = at;
|
|
187
|
+
entry.dayCount += 1;
|
|
188
|
+
entry.inFlight += 1;
|
|
189
|
+
// What is holding the slot, so a later skip can SAY another review took
|
|
190
|
+
// precedence instead of a bare "concurrency".
|
|
191
|
+
entry.inFlightTrigger = String(trigger ?? '');
|
|
192
|
+
persist(workspace, entry);
|
|
193
|
+
return {
|
|
194
|
+
action: 'review',
|
|
195
|
+
config: cfg,
|
|
196
|
+
capability: PROACTIVE_REVIEW_CAPABILITY,
|
|
197
|
+
workspace: String(workspace ?? ''),
|
|
198
|
+
trigger: String(trigger ?? ''),
|
|
199
|
+
sourceVersion: sourceVersion == null ? null : String(sourceVersion),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Called when the review run reaches a terminal state (success, failure or
|
|
204
|
+
// cancellation), so the concurrency slot is never leaked. `undo` is for the
|
|
205
|
+
// failure path where the review was never actually queued: it returns the
|
|
206
|
+
// budget unit and releases the version, instead of burning an audit that
|
|
207
|
+
// will never happen. Never allocates state for an unknown workspace.
|
|
208
|
+
function release(workspace, { undo = false, sourceVersion = null } = {}) {
|
|
209
|
+
const entry = state.get(String(workspace ?? ''));
|
|
210
|
+
if (!entry) return;
|
|
211
|
+
if (entry.inFlight > 0) entry.inFlight -= 1;
|
|
212
|
+
if (entry.inFlight <= 0) entry.inFlightTrigger = null;
|
|
213
|
+
if (undo) {
|
|
214
|
+
if (entry.dayCount > 0) entry.dayCount -= 1;
|
|
215
|
+
if (sourceVersion != null) entry.seen.delete(String(sourceVersion));
|
|
216
|
+
}
|
|
217
|
+
persist(workspace, entry);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Display-only: what the budget panel reads. A read must not allocate.
|
|
221
|
+
function snapshot(workspace) {
|
|
222
|
+
const entry = state.get(String(workspace ?? ''));
|
|
223
|
+
if (!entry) return { lastFiredAt: null, runsToday: 0, inFlight: 0, inFlightTrigger: null, seenVersions: 0 };
|
|
224
|
+
return {
|
|
225
|
+
lastFiredAt: entry.lastFiredAt,
|
|
226
|
+
runsToday: entry.dayCount,
|
|
227
|
+
inFlight: entry.inFlight,
|
|
228
|
+
inFlightTrigger: entry.inFlightTrigger ?? null,
|
|
229
|
+
seenVersions: entry.seen.size,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// How many workspaces carry scheduler state — a cheap diagnostic, and the
|
|
234
|
+
// proof that a display read never allocates.
|
|
235
|
+
function workspaceCount() {
|
|
236
|
+
return state.size;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { decide, release, snapshot, workspaceCount };
|
|
240
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
PROACTIVE_DEFAULTS,
|
|
10
|
+
buildProactiveReviewObjective,
|
|
11
|
+
createProactiveReviewScheduler,
|
|
12
|
+
normalizeProactiveConfig,
|
|
13
|
+
triggerForTask,
|
|
14
|
+
} from './proactiveReviewScheduler.js';
|
|
15
|
+
|
|
16
|
+
test('triggerForTask derives the two knowledge facts from the real capabilities', () => {
|
|
17
|
+
assert.equal(triggerForTask({ capability: 'knowledge.update', operation: 'ingest_apply' }), 'knowledge.ingested');
|
|
18
|
+
assert.equal(triggerForTask({ operation: 'ingest' }), 'knowledge.ingested');
|
|
19
|
+
assert.equal(triggerForTask({ capability: 'knowledge.rebuild', operation: 'run' }), 'knowledge.rebuilt');
|
|
20
|
+
assert.equal(triggerForTask({ operation: 'ingest_rebuild' }), 'knowledge.rebuilt');
|
|
21
|
+
assert.equal(triggerForTask({ capability: 'knowledge.pipeline', operation: 'pipeline' }), 'knowledge.ingested');
|
|
22
|
+
// A dry-run takes a read lock and writes only .wiki/ingest-plans/: the corpus
|
|
23
|
+
// has not moved, so there is nothing new to audit.
|
|
24
|
+
assert.equal(triggerForTask({ capability: 'knowledge.update', operation: 'ingest_plan' }), null);
|
|
25
|
+
assert.equal(triggerForTask({ operation: 'ingest_plan' }), null);
|
|
26
|
+
assert.equal(triggerForTask({ capability: 'document.build', operation: 'build' }), null);
|
|
27
|
+
assert.equal(triggerForTask({ capability: 'knowledge.check', operation: 'lint' }), null);
|
|
28
|
+
assert.equal(triggerForTask({}), null);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('normalizeProactiveConfig stays disabled on anything malformed', () => {
|
|
32
|
+
assert.equal(normalizeProactiveConfig(undefined).enabled, false);
|
|
33
|
+
assert.equal(normalizeProactiveConfig('yes').enabled, false);
|
|
34
|
+
assert.equal(normalizeProactiveConfig({ enabled: 'true' }).enabled, false, 'only a real boolean opts in');
|
|
35
|
+
const config = normalizeProactiveConfig({ enabled: true, cooldownMs: -5, budget: { runsPerDay: 2 } });
|
|
36
|
+
assert.equal(config.enabled, true);
|
|
37
|
+
assert.equal(config.cooldownMs, PROACTIVE_DEFAULTS.cooldownMs, 'a negative cooldown falls back');
|
|
38
|
+
assert.equal(config.runsPerDay, 2);
|
|
39
|
+
assert.equal(config.staleAfterDays, PROACTIVE_DEFAULTS.staleAfterDays);
|
|
40
|
+
assert.equal(normalizeProactiveConfig({ enabled: true, staleAfterDays: 30 }).staleAfterDays, 30);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the same source version never queues a second audit', () => {
|
|
44
|
+
const scheduler = createProactiveReviewScheduler();
|
|
45
|
+
// concurrency 2 so the second decision reaches the cooldown, not the slot.
|
|
46
|
+
const config = { enabled: true, concurrency: 2 };
|
|
47
|
+
assert.equal(
|
|
48
|
+
scheduler.decide({ workspace: 'docs', trigger: 'knowledge.ingested', sourceVersion: 'v1', config }).action,
|
|
49
|
+
'review',
|
|
50
|
+
);
|
|
51
|
+
const again = scheduler.decide({ workspace: 'docs', trigger: 'knowledge.ingested', sourceVersion: 'v1', config });
|
|
52
|
+
assert.equal(again.action, 'skip');
|
|
53
|
+
assert.equal(again.reason, 'duplicate');
|
|
54
|
+
const other = scheduler.decide({ workspace: 'docs', trigger: 'knowledge.ingested', sourceVersion: 'v2', config });
|
|
55
|
+
assert.equal(other.reason, 'cooldown', 'a different version still waits for the cooldown');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('cooldown and budget bound the spend per workspace', () => {
|
|
59
|
+
let clock = 1_000_000;
|
|
60
|
+
const scheduler = createProactiveReviewScheduler({ now: () => clock });
|
|
61
|
+
const config = { enabled: true, cooldownMs: 100, budget: { runsPerDay: 2 }, concurrency: 2 };
|
|
62
|
+
assert.equal(
|
|
63
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'a', config }).action,
|
|
64
|
+
'review',
|
|
65
|
+
);
|
|
66
|
+
assert.equal(
|
|
67
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.rebuilt', sourceVersion: 'b', config }).reason,
|
|
68
|
+
'cooldown',
|
|
69
|
+
);
|
|
70
|
+
scheduler.release('w');
|
|
71
|
+
clock += 1_000;
|
|
72
|
+
assert.equal(
|
|
73
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.rebuilt', sourceVersion: 'b', config }).action,
|
|
74
|
+
'review',
|
|
75
|
+
);
|
|
76
|
+
scheduler.release('w');
|
|
77
|
+
clock += 1_000;
|
|
78
|
+
assert.equal(
|
|
79
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.rebuilt', sourceVersion: 'c', config }).reason,
|
|
80
|
+
'budget',
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('one in-flight review holds the concurrency slot until it is released', () => {
|
|
85
|
+
const scheduler = createProactiveReviewScheduler();
|
|
86
|
+
const config = { enabled: true, cooldownMs: 0, budget: { runsPerDay: 10 }, concurrency: 1 };
|
|
87
|
+
assert.equal(
|
|
88
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'a', config }).action,
|
|
89
|
+
'review',
|
|
90
|
+
);
|
|
91
|
+
assert.equal(
|
|
92
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'b', config }).reason,
|
|
93
|
+
'concurrency',
|
|
94
|
+
);
|
|
95
|
+
scheduler.release('w');
|
|
96
|
+
assert.equal(
|
|
97
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'b', config }).action,
|
|
98
|
+
'review',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('disabled or untriggered facts never start anything', () => {
|
|
103
|
+
const scheduler = createProactiveReviewScheduler();
|
|
104
|
+
assert.equal(scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', config: null }).reason, 'disabled');
|
|
105
|
+
assert.equal(
|
|
106
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.rebuilt', config: { enabled: false } }).reason,
|
|
107
|
+
'disabled',
|
|
108
|
+
);
|
|
109
|
+
assert.equal(
|
|
110
|
+
scheduler.decide({
|
|
111
|
+
workspace: 'w',
|
|
112
|
+
trigger: 'knowledge.rebuilt',
|
|
113
|
+
config: { enabled: true, triggers: ['knowledge.ingested'] },
|
|
114
|
+
}).reason,
|
|
115
|
+
'trigger_disabled',
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the proactive objective names the evidence the scan already found', () => {
|
|
120
|
+
const objective = buildProactiveReviewObjective({
|
|
121
|
+
trigger: 'knowledge.stale',
|
|
122
|
+
sourceVersion: 'sha-9',
|
|
123
|
+
evidence: {
|
|
124
|
+
kind: 'stale',
|
|
125
|
+
counts: { aged: 1, vanishedArchive: 1, vanishedPage: 57 },
|
|
126
|
+
items: [
|
|
127
|
+
{ kind: 'aged', path: 'raw/ingested/a.md' },
|
|
128
|
+
{ kind: 'vanished-page', path: 'wiki/concepts/saas/gone.md' },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
assert.match(objective, /audit the workspace/);
|
|
133
|
+
assert.match(objective, /knowledge\.stale/);
|
|
134
|
+
assert.match(objective, /sha-9/);
|
|
135
|
+
assert.match(objective, /1 aged source\(s\)/);
|
|
136
|
+
assert.match(objective, /1 vanished archive\(s\)/);
|
|
137
|
+
assert.match(objective, /57 vanished page\(s\)/);
|
|
138
|
+
assert.match(objective, /raw\/ingested\/a\.md/);
|
|
139
|
+
assert.match(objective, /wiki\/concepts\/saas\/gone\.md/);
|
|
140
|
+
// Routing is explicit (capabilityPlan), so an evidence path may contain any
|
|
141
|
+
// word — the alias resolver is no longer on the path.
|
|
142
|
+
const noEvidence = buildProactiveReviewObjective({ trigger: 'knowledge.ingested', sourceVersion: 'v1' });
|
|
143
|
+
assert.match(noEvidence, /audit the workspace/);
|
|
144
|
+
assert.doesNotMatch(noEvidence, /deterministic scan/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('reading the budget, or releasing an unknown workspace, never allocates', () => {
|
|
148
|
+
const scheduler = createProactiveReviewScheduler();
|
|
149
|
+
assert.deepEqual(scheduler.snapshot('never-seen'), {
|
|
150
|
+
lastFiredAt: null, runsToday: 0, inFlight: 0, inFlightTrigger: null, seenVersions: 0,
|
|
151
|
+
});
|
|
152
|
+
scheduler.release('never-seen');
|
|
153
|
+
scheduler.release('never-seen', { undo: true });
|
|
154
|
+
assert.equal(scheduler.workspaceCount(), 0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('the seen set is pruned with the daily window, not grown forever', () => {
|
|
158
|
+
let clock = 1_000_000;
|
|
159
|
+
const scheduler = createProactiveReviewScheduler({ now: () => clock });
|
|
160
|
+
const config = { enabled: true, cooldownMs: 0, budget: { runsPerDay: 10 }, concurrency: 5 };
|
|
161
|
+
assert.equal(
|
|
162
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'v1', config }).action,
|
|
163
|
+
'review',
|
|
164
|
+
);
|
|
165
|
+
assert.equal(
|
|
166
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'v1', config }).reason,
|
|
167
|
+
'duplicate',
|
|
168
|
+
);
|
|
169
|
+
// The window rolls: dedup starts clean rather than holding every version ever.
|
|
170
|
+
scheduler.release('w');
|
|
171
|
+
clock += 24 * 60 * 60 * 1000;
|
|
172
|
+
assert.equal(
|
|
173
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'v1', config }).action,
|
|
174
|
+
'review',
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('an undone reservation returns the budget unit and the version', () => {
|
|
179
|
+
const scheduler = createProactiveReviewScheduler();
|
|
180
|
+
const config = { enabled: true, cooldownMs: 0, budget: { runsPerDay: 1 }, concurrency: 1 };
|
|
181
|
+
assert.equal(
|
|
182
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'v1', config }).action,
|
|
183
|
+
'review',
|
|
184
|
+
);
|
|
185
|
+
scheduler.release('w', { undo: true, sourceVersion: 'v1' });
|
|
186
|
+
// Neither the budget nor the version was consumed: the audit can still happen.
|
|
187
|
+
const again = scheduler.decide({ workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'v1', config });
|
|
188
|
+
assert.equal(again.action, 'review');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('the in-flight review names itself, so a skip can say who holds the slot', () => {
|
|
192
|
+
const scheduler = createProactiveReviewScheduler();
|
|
193
|
+
const config = { enabled: true, cooldownMs: 0, budget: { runsPerDay: 10 }, concurrency: 1 };
|
|
194
|
+
scheduler.decide({ workspace: 'w', trigger: 'knowledge.conflict_detected', sourceVersion: 'f1', config });
|
|
195
|
+
assert.equal(scheduler.snapshot('w').inFlightTrigger, 'knowledge.conflict_detected');
|
|
196
|
+
scheduler.release('w');
|
|
197
|
+
assert.equal(scheduler.snapshot('w').inFlightTrigger, null);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
test('restart preserves dedup, cooldown and daily spend in SQLite', () => {
|
|
202
|
+
const dir = mkdtempSync(join(tmpdir(), 'proactive-restart-'));
|
|
203
|
+
const path = join(dir, 'runtime.db');
|
|
204
|
+
let db = new DatabaseSync(path);
|
|
205
|
+
try {
|
|
206
|
+
let clock = 1000;
|
|
207
|
+
const make = () => createProactiveReviewScheduler({ db, now: () => clock });
|
|
208
|
+
const request = { workspace: 'w', trigger: 'knowledge.ingested', sourceVersion: 'v1',
|
|
209
|
+
config: { enabled: true, cooldownMs: 100, budget: { runsPerDay: 1 } } };
|
|
210
|
+
const first = make();
|
|
211
|
+
assert.equal(first.decide(request).action, 'review');
|
|
212
|
+
first.release('w');
|
|
213
|
+
db.close();
|
|
214
|
+
db = new DatabaseSync(path);
|
|
215
|
+
const restarted = make();
|
|
216
|
+
assert.equal(restarted.decide(request).reason, 'duplicate');
|
|
217
|
+
assert.equal(restarted.decide({ ...request, sourceVersion: 'v2' }).reason, 'cooldown');
|
|
218
|
+
clock += 101;
|
|
219
|
+
assert.equal(restarted.decide({ ...request, sourceVersion: 'v2' }).reason, 'budget');
|
|
220
|
+
clock += 24 * 60 * 60 * 1000;
|
|
221
|
+
assert.equal(restarted.decide({ ...request, sourceVersion: 'v2' }).action, 'review');
|
|
222
|
+
} finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('the global slot is shared across workspaces and returned on completion', () => {
|
|
226
|
+
const scheduler = createProactiveReviewScheduler();
|
|
227
|
+
const request = { trigger: 'knowledge.ingested', sourceVersion: 'v1',
|
|
228
|
+
config: { enabled: true, cooldownMs: 0, concurrency: 5 } };
|
|
229
|
+
assert.equal(scheduler.decide({ ...request, workspace: 'a' }).action, 'review');
|
|
230
|
+
assert.equal(scheduler.decide({ ...request, workspace: 'b' }).reason, 'concurrency');
|
|
231
|
+
scheduler.release('a');
|
|
232
|
+
assert.equal(scheduler.decide({ ...request, workspace: 'b' }).action, 'review');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
test('recovered and queued reviews hold the global slot until terminal or cancelled', () => {
|
|
237
|
+
let pending = [{ workspace: 'old', trigger: 'knowledge.stale' }];
|
|
238
|
+
const scheduler = createProactiveReviewScheduler({ pendingReviews: () => pending });
|
|
239
|
+
const request = { workspace: 'new', trigger: 'knowledge.ingested', config: { enabled: true } };
|
|
240
|
+
assert.equal(scheduler.decide(request).reason, 'concurrency');
|
|
241
|
+
pending = [];
|
|
242
|
+
assert.equal(scheduler.decide(request).action, 'review');
|
|
243
|
+
});
|